Updated on 2026-08-14

This commit is contained in:
Tangem 2026-02-25 16:39:35 +03:00
commit bfd903b43c
595 changed files with 12755 additions and 19837 deletions

View file

@ -104,7 +104,7 @@ internal class DefaultTangemPayEligibilityManager @Inject constructor(
map { wallet ->
async {
val isCustomer = onboardingRepository
.checkCustomerWallet(wallet.walletId)
.hasTangemPayInWallet(wallet.walletId)
.getOrNull() == true
wallet to isCustomer
}

View file

@ -0,0 +1,67 @@
package com.tangem.data.pay.converter
import com.tangem.data.pay.converter.PaymentAccountStatusDMConverter.convert
import com.tangem.data.pay.converter.PaymentAccountStatusDMConverter.convertBack
import com.tangem.datasource.local.visa.entity.PaymentAccountStatusDM
import com.tangem.domain.models.StatusSource
import com.tangem.domain.pay.PaymentAccountStatus
import com.tangem.utils.converter.TwoWayConverter
/**
* Two-way converter between [PaymentAccountStatus] and [PaymentAccountStatusDM].
*
* [convert] maps domain data model. Returns null for transient statuses that should not be persisted
* (Loading, ExposedDevice, Unavailable, NotSynced).
*
* [convertBack] maps data model domain. All restored statuses have [StatusSource.CACHE] as source.
*/
internal object PaymentAccountStatusDMConverter :
TwoWayConverter<PaymentAccountStatus, PaymentAccountStatusDM?> {
override fun convert(value: PaymentAccountStatus): PaymentAccountStatusDM? {
return when (value) {
is PaymentAccountStatus.NotCreated -> PaymentAccountStatusDM.NotCreated()
is PaymentAccountStatus.UnderReview -> PaymentAccountStatusDM.UnderReview(kycStatus = value.kycStatus)
is PaymentAccountStatus.IssuingCard -> PaymentAccountStatusDM.IssuingCard()
is PaymentAccountStatus.Locked -> PaymentAccountStatusDM.Locked()
is PaymentAccountStatus.Loaded -> PaymentAccountStatusDM.Loaded(
cardId = value.cardId,
lastFourDigits = value.lastFourDigits,
balance = value.balance,
currencyCode = value.currencyCode,
depositAddress = value.depositAddress,
isPinSet = value.isPinSet,
)
is PaymentAccountStatus.Error.CardIssueFailed -> PaymentAccountStatusDM.CardIssueFailed()
// Transient statuses are not persisted
is PaymentAccountStatus.Loading,
is PaymentAccountStatus.Error.ExposedDevice,
is PaymentAccountStatus.Error.Unavailable,
is PaymentAccountStatus.Error.NotSynced,
-> null
}
}
override fun convertBack(value: PaymentAccountStatusDM?): PaymentAccountStatus {
return when (value) {
is PaymentAccountStatusDM.CardIssueFailed -> PaymentAccountStatus.Error.CardIssueFailed
is PaymentAccountStatusDM.NotCreated -> PaymentAccountStatus.NotCreated
is PaymentAccountStatusDM.IssuingCard -> PaymentAccountStatus.IssuingCard(source = StatusSource.CACHE)
is PaymentAccountStatusDM.Locked -> PaymentAccountStatus.Locked(source = StatusSource.CACHE)
is PaymentAccountStatusDM.UnderReview -> PaymentAccountStatus.UnderReview(
source = StatusSource.CACHE,
kycStatus = value.kycStatus,
)
is PaymentAccountStatusDM.Loaded -> PaymentAccountStatus.Loaded(
source = StatusSource.CACHE,
cardId = value.cardId,
lastFourDigits = value.lastFourDigits,
balance = value.balance,
currencyCode = value.currencyCode,
depositAddress = value.depositAddress,
isPinSet = value.isPinSet,
)
null -> PaymentAccountStatus.Error.Unavailable(source = StatusSource.CACHE)
}
}
}

View file

@ -1,13 +1,28 @@
package com.tangem.data.pay.di
import android.content.Context
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.dataStoreFile
import com.squareup.moshi.Moshi
import com.tangem.data.pay.DefaultTangemPayCryptoCurrencyFactory
import com.tangem.data.pay.DefaultTangemPayEligibilityManager
import com.tangem.data.pay.flow.DefaultPaymentAccountStatusFetcher
import com.tangem.data.pay.flow.DefaultPaymentAccountStatusProducer
import com.tangem.data.pay.repository.*
import com.tangem.data.pay.store.PaymentAccountStatusesStore
import com.tangem.data.pay.usecase.DefaultGetTangemPayCurrencyStatusUseCase
import com.tangem.data.pay.usecase.DefaultGetTangemPayCustomerIdUseCase
import com.tangem.data.pay.usecase.DefaultTangemPayWithdrawUseCase
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.visa.entity.PaymentAccountStatusDM
import com.tangem.datasource.utils.MoshiDataStoreSerializer
import com.tangem.datasource.utils.mapWithStringKeyTypes
import com.tangem.domain.pay.TangemPayCryptoCurrencyFactory
import com.tangem.domain.pay.TangemPayEligibilityManager
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
import com.tangem.domain.pay.flow.PaymentAccountStatusProducer
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
import com.tangem.domain.pay.repository.*
import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase
import com.tangem.domain.pay.usecase.TangemPayMainScreenCustomerInfoUseCase
@ -16,11 +31,15 @@ import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase
import com.tangem.domain.tangempay.TangemPayWithdrawUseCase
import com.tangem.domain.tangempay.repository.TangemPayTxHistoryRepository
import com.tangem.security.DeviceSecurityInfoProvider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import javax.inject.Singleton
@Module
@ -75,7 +94,51 @@ internal interface TangemPayDataModule {
@Singleton
fun bindTangemPayEligibilityManager(impl: DefaultTangemPayEligibilityManager): TangemPayEligibilityManager
@Binds
@Singleton
fun bindPaymentAccountStatusProducerFactory(
impl: DefaultPaymentAccountStatusProducer.Factory,
): PaymentAccountStatusProducer.Factory
@Binds
@Singleton
fun bindPaymentAccountStatusFetcher(impl: DefaultPaymentAccountStatusFetcher): PaymentAccountStatusFetcher
companion object {
@Provides
@Singleton
fun providePaymentAccountStatusesStore(
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
dispatchers: CoroutineDispatcherProvider,
): PaymentAccountStatusesStore {
return PaymentAccountStatusesStore(
runtimeStore = RuntimeSharedStore(),
persistenceDataStore = DataStoreFactory.create(
serializer = MoshiDataStoreSerializer(
moshi = moshi,
types = mapWithStringKeyTypes<PaymentAccountStatusDM>(),
defaultValue = emptyMap(),
),
produceFile = { context.dataStoreFile(fileName = "payment_account_statuses") },
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
),
dispatchers = dispatchers,
)
}
@Provides
@Singleton
fun providePaymentAccountStatusSupplier(
factory: PaymentAccountStatusProducer.Factory,
): PaymentAccountStatusSupplier {
return object : PaymentAccountStatusSupplier(
factory = factory,
keyCreator = { "payment_account_status_${it.userWalletId.stringValue}" },
) {}
}
@Provides
@Singleton
fun provideTangemPayMainScreenCustomerInfoUseCase(

View file

@ -0,0 +1,178 @@
package com.tangem.data.pay.flow
import arrow.core.Either
import com.tangem.data.pay.store.PaymentAccountStatusesStore
import com.tangem.domain.core.utils.eitherOn
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.kyc.KycStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.PaymentAccountStatus
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
import com.tangem.domain.pay.model.CustomerInfo
import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.repository.CustomerOrderRepository
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.security.DeviceSecurityInfoProvider
import com.tangem.security.isSecurityExposed
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import timber.log.Timber
import javax.inject.Inject
private const val TAG = "PaymentAccountStatusFetcher"
internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
private val paymentAccountStatusesStore: PaymentAccountStatusesStore,
private val onboardingRepository: OnboardingRepository,
private val customerOrderRepository: CustomerOrderRepository,
private val deviceSecurity: DeviceSecurityInfoProvider,
private val dispatchers: CoroutineDispatcherProvider,
) : PaymentAccountStatusFetcher {
override suspend fun invoke(params: PaymentAccountStatusFetcher.Params): Either<Throwable, Unit> =
eitherOn(dispatchers.default) {
Timber.tag(TAG).i("fetch: ${params.userWalletId.stringValue}")
if (deviceSecurity.isSecurityExposed()) {
Timber.tag(TAG).i("fetch security info: rooted: ${deviceSecurity.isRooted}")
Timber.tag(TAG).i("fetch security info: xposed: ${deviceSecurity.isXposed}")
Timber.tag(TAG).i("fetch security info: bootloader unlocked: ${deviceSecurity.isBootloaderUnlocked}")
return@eitherOn paymentAccountStatusesStore.store(
userWalletId = params.userWalletId,
status = PaymentAccountStatus.Error.ExposedDevice,
)
}
val status = onboardingRepository.hasTangemPayInWallet(userWalletId = params.userWalletId)
.fold(
ifLeft = { error ->
Timber.tag(TAG).e("Failed check wallet ${params.userWalletId}: ${error.javaClass.simpleName}")
when (error) {
is VisaApiError.NotPaeraCustomer -> PaymentAccountStatus.NotCreated
else -> PaymentAccountStatus.Error.Unavailable(source = StatusSource.ACTUAL)
}
},
ifRight = { hasTangemPay ->
proceedHasTangemPayResult(userWalletId = params.userWalletId, hasTangemPay = hasTangemPay)
},
)
Timber.tag(TAG).i("invoke status ${params.userWalletId}: $status")
paymentAccountStatusesStore.store(userWalletId = params.userWalletId, status = status)
}
private suspend fun proceedHasTangemPayResult(
userWalletId: UserWalletId,
hasTangemPay: Boolean,
): PaymentAccountStatus {
Timber.tag(TAG).i("proceedHasTangemPayResult for $userWalletId hasTangemPay: $hasTangemPay")
return if (hasTangemPay) {
fetchTangemPayAccountStatus(userWalletId = userWalletId)
} else {
PaymentAccountStatus.NotCreated
}
}
private suspend fun fetchTangemPayAccountStatus(userWalletId: UserWalletId): PaymentAccountStatus {
val prevResult = paymentAccountStatusesStore.getSyncOrNull(userWalletId)
if (prevResult == null || prevResult is PaymentAccountStatus.Error) {
paymentAccountStatusesStore.store(userWalletId = userWalletId, status = PaymentAccountStatus.Loading)
}
return proceedWithOrderId(userWalletId = userWalletId)
}
private suspend fun proceedWithOrderId(userWalletId: UserWalletId): PaymentAccountStatus {
return if (!onboardingRepository.isTangemPayInitialDataProduced(userWalletId)) {
PaymentAccountStatus.Error.NotSynced
} else {
val orderId = onboardingRepository.getOrderId(userWalletId)
if (orderId != null) {
proceedWithOrderId(userWalletId = userWalletId, orderId = orderId)
} else {
proceedWithoutOrder(userWalletId = userWalletId)
}
}
}
private suspend fun proceedWithoutOrder(userWalletId: UserWalletId): PaymentAccountStatus {
return onboardingRepository.getCustomerInfo(userWalletId).fold(
ifLeft = { error ->
Timber.tag(TAG).e("proceedWithoutOrder $userWalletId error: $error")
error.mapToPaymentAccountStatus()
},
ifRight = { customerInfo ->
Timber.tag(TAG).i("proceedWithoutOrder data customerInfo $userWalletId")
val status = customerInfo.mapToPaymentAccountStatus()
if (status is PaymentAccountStatus.IssuingCard && customerInfo.kycStatus == KycStatus.APPROVED) {
// If order id wasn't saved -> start order creation and get customer info
onboardingRepository.createOrder(userWalletId)
}
status
},
)
}
private suspend fun proceedWithOrderId(userWalletId: UserWalletId, orderId: String): PaymentAccountStatus {
return customerOrderRepository.getOrderData(userWalletId, orderId = orderId).fold(
ifLeft = { error ->
Timber.tag(TAG).e("proceedWithOrderId $userWalletId orderId: $orderId error: $error")
error.mapToPaymentAccountStatus()
},
ifRight = { orderData ->
Timber.tag(TAG).i("proceedWithOrderId $userWalletId: $orderId status: ${orderData.status}")
when (orderData.status) {
// Kyc is passed and user waits for order creation -> no need to get customer info
OrderStatus.NEW,
OrderStatus.PROCESSING,
-> PaymentAccountStatus.IssuingCard(source = StatusSource.ACTUAL)
OrderStatus.CANCELED -> {
// If order was cancelled -> clear previous order from local storage and start order creation
onboardingRepository.clearOrderId(userWalletId)
onboardingRepository.createOrder(userWalletId)
PaymentAccountStatus.Error.CardIssueFailed
}
OrderStatus.COMPLETED -> {
// Order was completed -> clear order id and get customer info
onboardingRepository.clearOrderId(userWalletId)
onboardingRepository.getCustomerInfo(userWalletId = userWalletId)
.fold(
ifLeft = { it.mapToPaymentAccountStatus() },
ifRight = { customerInfo -> customerInfo.mapToPaymentAccountStatus() },
)
}
OrderStatus.UNKNOWN -> PaymentAccountStatus.Error.Unavailable(source = StatusSource.ACTUAL)
}
},
)
}
private fun CustomerInfo.mapToPaymentAccountStatus(): PaymentAccountStatus {
val cardInfo = this.cardInfo
val productInstance = this.productInstance
return if (kycStatus != KycStatus.APPROVED && !customerId.isNullOrEmpty()) {
PaymentAccountStatus.UnderReview(source = StatusSource.ACTUAL, kycStatus = kycStatus)
} else if (cardInfo != null && productInstance != null) {
PaymentAccountStatus.Loaded(
source = StatusSource.ACTUAL,
cardId = productInstance.cardId,
lastFourDigits = cardInfo.lastFourDigits,
balance = cardInfo.balance,
currencyCode = cardInfo.currencyCode,
depositAddress = cardInfo.depositAddress,
isPinSet = cardInfo.isPinSet,
)
} else {
PaymentAccountStatus.IssuingCard(source = StatusSource.ACTUAL)
}
}
private fun VisaApiError.mapToPaymentAccountStatus(): PaymentAccountStatus {
return when (this) {
is VisaApiError.RefreshTokenExpired -> PaymentAccountStatus.Error.NotSynced
is VisaApiError.NotPaeraCustomer -> PaymentAccountStatus.NotCreated
else -> PaymentAccountStatus.Error.Unavailable(source = StatusSource.ACTUAL)
}
}
}

View file

@ -0,0 +1,37 @@
package com.tangem.data.pay.flow
import arrow.core.Option
import arrow.core.some
import com.tangem.data.pay.store.PaymentAccountStatusesStore
import com.tangem.domain.core.flow.FlowProducerTools
import com.tangem.domain.models.StatusSource
import com.tangem.domain.pay.PaymentAccountStatus
import com.tangem.domain.pay.flow.PaymentAccountStatusProducer
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onEmpty
internal class DefaultPaymentAccountStatusProducer @AssistedInject constructor(
@Assisted private val params: PaymentAccountStatusProducer.Params,
override val flowProducerTools: FlowProducerTools,
private val paymentAccountStatusesStore: PaymentAccountStatusesStore,
private val dispatchers: CoroutineDispatcherProvider,
) : PaymentAccountStatusProducer {
override val fallback: Option<PaymentAccountStatus>
get() = PaymentAccountStatus.Error.Unavailable(source = StatusSource.ACTUAL).some()
override fun produce(): Flow<PaymentAccountStatus> {
return paymentAccountStatusesStore.get(userWalletId = params.userWalletId)
.onEmpty { emit(value = PaymentAccountStatus.NotCreated) }
.flowOn(dispatchers.default)
}
@AssistedFactory
interface Factory : PaymentAccountStatusProducer.Factory {
override fun create(params: PaymentAccountStatusProducer.Params): DefaultPaymentAccountStatusProducer
}
}

View file

@ -2,6 +2,7 @@ package com.tangem.data.pay.repository
import arrow.core.Either
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.models.response.OrderResponse
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.OrderData
import com.tangem.domain.pay.model.OrderStatus
@ -19,11 +20,11 @@ internal class DefaultCustomerOrderRepository @Inject constructor(
tangemPayApi.getOrder(authHeader = authHeader, orderId = orderId)
}.map { response ->
val status = when (response.result?.status) {
null -> OrderStatus.UNKNOWN
OrderStatus.NEW.apiName -> OrderStatus.NEW
OrderStatus.PROCESSING.apiName -> OrderStatus.PROCESSING
OrderStatus.COMPLETED.apiName -> OrderStatus.COMPLETED
else -> OrderStatus.CANCELED
null -> OrderStatus.PROCESSING
OrderResponse.Result.Status.NEW -> OrderStatus.NEW
OrderResponse.Result.Status.PROCESSING -> OrderStatus.PROCESSING
OrderResponse.Result.Status.COMPLETED -> OrderStatus.COMPLETED
OrderResponse.Result.Status.CANCELED -> OrderStatus.CANCELED
}
OrderData(
status = status,

View file

@ -10,6 +10,7 @@ import com.tangem.datasource.api.pay.models.response.CustomerMeResponse
import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore
import com.tangem.datasource.local.visa.TangemPayStorage
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.kyc.KycStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.datasource.TangemPayAuthDataSource
@ -27,9 +28,6 @@ import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
private const val VALID_STATUS = "valid"
private const val APPROVED_KYC_STATUS = "approved"
private const val IN_PROGRESS_KYC_STATUS = "in_progress"
private const val DECLINED_KYC_STATUS = "declined"
private const val TAG = "TangemPay: OnboardingRepository"
@Suppress("LongParameterList")
@ -145,7 +143,6 @@ internal class DefaultOnboardingRepository @Inject constructor(
lastFourDigits = card.cardNumberEnd,
balance = fiatBalance.availableBalance,
currencyCode = fiatBalance.currency,
customerWalletAddress = paymentAccount.customerWalletAddress,
depositAddress = response.depositAddress,
isPinSet = response.card?.isPinSet == true,
)
@ -159,19 +156,19 @@ internal class DefaultOnboardingRepository @Inject constructor(
}
cardFrozenStateStore.store(key = instance.cardId, value = cardFrozenState)
ProductInstance(id = instance.id, cardId = instance.cardId, cardFrozenState = cardFrozenState)
ProductInstance(id = instance.id, cardId = instance.cardId)
}
return CustomerInfo(
customerId = response?.id,
productInstance = productInstance,
kycStatus = getKycStatus(status = response?.kyc?.status),
kycStatus = KycStatus.fromString(status = response?.kyc?.status),
cardInfo = cardInfo,
).also {
lastFetchedCustomerInfoMap[userWalletId] = it
}
}
override suspend fun checkCustomerWallet(userWalletId: UserWalletId): Either<VisaApiError, Boolean> {
override suspend fun hasTangemPayInWallet(userWalletId: UserWalletId): Either<VisaApiError, Boolean> {
val hasTangemPay = tangemPayStorage.checkCustomerWalletResult(userWalletId)
if (hasTangemPay != null) {
return Either.Right(hasTangemPay)
@ -228,13 +225,4 @@ internal class DefaultOnboardingRepository @Inject constructor(
setHideMainOnboardingBanner(userWalletId)
}
}
private fun getKycStatus(status: String?): CustomerInfo.KycStatus {
return when (status?.lowercase()) {
IN_PROGRESS_KYC_STATUS -> CustomerInfo.KycStatus.PENDING
DECLINED_KYC_STATUS -> CustomerInfo.KycStatus.REJECTED
APPROVED_KYC_STATUS -> CustomerInfo.KycStatus.APPROVED
else -> CustomerInfo.KycStatus.INIT
}
}
}

View file

@ -16,10 +16,10 @@ import com.tangem.datasource.api.pay.models.request.CardDetailsRequest
import com.tangem.datasource.api.pay.models.request.FreezeUnfreezeCardRequest
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.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.model.SetPinResult
import com.tangem.domain.pay.model.TangemPayCardBalance
import com.tangem.domain.pay.model.TangemPayCardDetails
@ -279,15 +279,15 @@ internal class DefaultTangemPayCardDetailsRepository @Inject constructor(
orderStatus.onRight { response ->
val status = response.result?.status
if (status == OrderStatus.COMPLETED.apiName || status == OrderStatus.CANCELED.apiName) {
if (status == Status.COMPLETED || status == Status.CANCELED) {
// Remove from jobs
pollingJobs.remove(key = orderId)
// Final card state
val finalState = when {
status == OrderStatus.COMPLETED.apiName && isFreeze
status == Status.COMPLETED && isFreeze
-> TangemPayCardFrozenState.Frozen
status == OrderStatus.COMPLETED.apiName && !isFreeze
status == Status.COMPLETED && !isFreeze
-> TangemPayCardFrozenState.Unfrozen
else -> return@launch
}

View file

@ -77,34 +77,22 @@ internal class DefaultTangemPayTxHistoryRepository @Inject constructor(
limit: Int,
): List<TangemPayTxHistoryItem> {
cacheRegistry.invokeOnExpire(
key = getCacheKey(customerWalletAddress = config.customerWalletAddress, cursor = cursor),
key = getCacheKey(userWalletId = userWalletId, cursor = cursor),
skipCache = config.shouldRefresh,
block = {
fetch(
userWalletId = userWalletId,
customerWalletAddress = config.customerWalletAddress,
cursor = cursor,
pageSize = limit,
)
},
block = { fetch(userWalletId = userWalletId, cursor = cursor, pageSize = limit) },
)
return txHistoryItemsStore.getSyncOrNull(
key = config.customerWalletAddress,
key = userWalletId.stringValue,
cursor = cursor ?: INITIAL_CURSOR,
).orEmpty()
}
private fun getCacheKey(customerWalletAddress: String, cursor: String?): String {
return "tangem_pay_tx_history_${customerWalletAddress}_${cursor ?: INITIAL_CURSOR}"
private fun getCacheKey(userWalletId: UserWalletId, cursor: String?): String {
return "tangem_pay_tx_history_${userWalletId.stringValue}_${cursor ?: INITIAL_CURSOR}"
}
private suspend fun fetch(
userWalletId: UserWalletId,
customerWalletAddress: String,
cursor: String?,
pageSize: Int,
) {
private suspend fun fetch(userWalletId: UserWalletId, cursor: String?, pageSize: Int) {
requestPerformer.performRequest(userWalletId = userWalletId) { authHeader ->
visaApi.getTangemPayTxHistory(authHeader = authHeader, limit = pageSize, cursor = cursor)
}.onLeft {
@ -112,7 +100,7 @@ internal class DefaultTangemPayTxHistoryRepository @Inject constructor(
}.onRight { response ->
val result = response.result
val items = txHistoryItemConverter.convertList(result.transactions).filterNotNull()
txHistoryItemsStore.store(key = customerWalletAddress, cursor = cursor ?: INITIAL_CURSOR, value = items)
txHistoryItemsStore.store(key = userWalletId.stringValue, cursor = cursor ?: INITIAL_CURSOR, value = items)
}
}
}

View file

@ -0,0 +1,86 @@
package com.tangem.data.pay.store
import androidx.datastore.core.DataStore
import com.tangem.data.pay.converter.PaymentAccountStatusDMConverter
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.visa.entity.PaymentAccountStatusDM
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.PaymentAccountStatus
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.launch
import timber.log.Timber
internal typealias WalletIdWithPaymentStatus = Map<String, PaymentAccountStatus>
internal typealias WalletIdWithPaymentStatusDM = Map<String, PaymentAccountStatusDM>
/**
* Store for payment account statuses with dual storage (runtime + persistence).
*
* @property runtimeStore runtime store for fast in-memory access
* @property persistenceDataStore persistence store for caching across app restarts
*/
internal class PaymentAccountStatusesStore(
private val runtimeStore: RuntimeSharedStore<WalletIdWithPaymentStatus>,
private val persistenceDataStore: DataStore<WalletIdWithPaymentStatusDM>,
dispatchers: CoroutineDispatcherProvider,
) {
private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io)
init {
scope.launch {
try {
val cachedStatuses = persistenceDataStore.data.firstOrNull() ?: return@launch
runtimeStore.store(
value = cachedStatuses.mapValues { (_, statusDM) ->
PaymentAccountStatusDMConverter.convertBack(statusDM)
},
)
} catch (e: Exception) {
Timber.e(e, "Error while loading cached payment account statuses")
}
}
}
fun get(userWalletId: UserWalletId): Flow<PaymentAccountStatus> {
return runtimeStore.get().mapNotNull { it[userWalletId.stringValue] }
}
suspend fun getSyncOrNull(userWalletId: UserWalletId): PaymentAccountStatus? {
return runtimeStore.getSyncOrNull()?.get(userWalletId.stringValue)
}
suspend fun store(userWalletId: UserWalletId, status: PaymentAccountStatus) {
coroutineScope {
launch { storeInRuntime(userWalletId = userWalletId, status = status) }
launch { storeInPersistence(userWalletId = userWalletId, status = status) }
}
}
suspend fun contains(userWalletId: UserWalletId): Boolean {
return runtimeStore.getSyncOrDefault(emptyMap()).containsKey(userWalletId.stringValue)
}
private suspend fun storeInRuntime(userWalletId: UserWalletId, status: PaymentAccountStatus) {
runtimeStore.update(default = emptyMap()) { stored ->
stored.toMutableMap().apply {
put(key = userWalletId.stringValue, value = status)
}
}
}
private suspend fun storeInPersistence(userWalletId: UserWalletId, status: PaymentAccountStatus) {
val statusDM = PaymentAccountStatusDMConverter.convert(value = status) ?: return
persistenceDataStore.updateData { storedStatuses ->
storedStatuses.toMutableMap().apply {
put(key = userWalletId.stringValue, value = statusDM)
}
}
}
}