Updated on 2026-08-14

This commit is contained in:
Tangem 2025-09-21 15:32:47 +04:00
parent 6d3a1d6e89
commit 99a8c6623b
15 changed files with 328 additions and 171 deletions

View file

@ -1,6 +1,5 @@
package com.tangem.data.pay.repository
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.domain.pay.KycStartInfo
import com.tangem.domain.pay.repository.KycRepository
@ -16,9 +15,9 @@ internal class DefaultKycRepository @Inject constructor(
override suspend fun getKycStartInfo() = withContext(dispatchers.io) {
requestHelper.request { authHeader ->
tangemPayApi.getKycAccess(authHeader = authHeader).getOrThrow().result
tangemPayApi.getKycAccess(authHeader = authHeader)
}.map {
KycStartInfo(token = it.token, locale = it.locale)
KycStartInfo(token = it.result.token, locale = it.result.locale)
}
}
}

View file

@ -3,13 +3,11 @@ package com.tangem.data.pay.repository
import arrow.core.Either
import arrow.core.raise.either
import com.tangem.core.error.UniversalError
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.models.request.DeeplinkValidityRequest
import com.tangem.domain.pay.model.CustomerInfo
import com.tangem.domain.pay.model.ProductInstance
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.visa.error.VisaApiError
import javax.inject.Inject
private const val VALID_STATUS = "valid"
@ -21,19 +19,17 @@ internal class DefaultOnboardingRepository @Inject constructor(
override suspend fun validateDeeplink(link: String): Either<UniversalError, Boolean> = either {
return requestHelper.request {
tangemPayApi.validateDeeplink(DeeplinkValidityRequest(link)).getOrThrow().result
?: raise(VisaApiError.UnknownWithoutCode)
}.map { result -> result.status == VALID_STATUS }
tangemPayApi.validateDeeplink(DeeplinkValidityRequest(link))
}.map { it.result?.status == VALID_STATUS }
}
override suspend fun getCustomerInfo(): Either<UniversalError, CustomerInfo> = either {
return requestHelper.request { authHeader ->
val response = tangemPayApi.getCustomerMe(authHeader).getOrThrow()
response.result ?: raise(VisaApiError.UnknownWithoutCode)
}.map { result ->
tangemPayApi.getCustomerMe(authHeader)
}.map {
CustomerInfo(
productInstance = result.productInstance?.let { ProductInstance(id = it.id, status = it.status) },
kycStatus = result.kyc?.status,
productInstance = it.result?.productInstance?.let { ProductInstance(id = it.id, status = it.status) },
kycStatus = it.result?.kyc?.status,
)
}
}

View file

@ -1,30 +1,27 @@
package com.tangem.data.pay.repository
import com.tangem.data.common.cache.CacheRegistry
import com.tangem.data.visa.utils.VisaApiRequestMaker
import com.tangem.data.visa.utils.VisaTxHistoryItemConverter
import com.tangem.data.visa.utils.TangemPayTxHistoryItemConverter
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.models.response.VisaTxHistoryResponse
import com.tangem.datasource.local.visa.TangemPayTxHistoryItemsStore
import com.tangem.domain.models.wallet.UserWalletId
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.model.VisaTxHistoryItem
import com.tangem.domain.visa.model.TangemPayTxHistoryItem
import com.tangem.pagination.BatchFetchResult
import com.tangem.pagination.BatchListSource
import com.tangem.pagination.fetcher.LimitOffsetBatchFetcher
import com.tangem.pagination.fetcher.BatchFetcher
import com.tangem.pagination.fetcher.CursorBatchFetcher
import com.tangem.pagination.toBatchFlow
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.delay
import org.joda.time.DateTime
import java.math.BigDecimal
import javax.inject.Inject
@Suppress("UnusedPrivateMember", "MagicNumber", "UnnecessaryParentheses")
private const val INITIAL_CURSOR = "initial_cursor_key"
internal class DefaultTangemPayTxHistoryRepository @Inject constructor(
private val visaApiRequestMaker: VisaApiRequestMaker,
private val requestPerformer: TangemPayRequestPerformer,
private val visaApi: TangemPayApi,
private val cacheRegistry: CacheRegistry,
private val txHistoryItemsStore: TangemPayTxHistoryItemsStore,
@ -45,137 +42,54 @@ internal class DefaultTangemPayTxHistoryRepository @Inject constructor(
private fun createFetcher(
batchSize: Int,
): LimitOffsetBatchFetcher<TangemPayTxHistoryListConfig, List<VisaTxHistoryItem>> {
return LimitOffsetBatchFetcher(
): BatchFetcher<TangemPayTxHistoryListConfig, List<TangemPayTxHistoryItem>> {
return CursorBatchFetcher(
prefetchDistance = batchSize,
batchSize = batchSize,
subFetcher = { config, _, isInitialLoading ->
val items = loadItems(config = config.params, offset = config.offset, limit = config.limit)
subFetcher = { request, _, _ ->
val items = loadItems(config = request.params, cursor = request.cursor, limit = request.limit)
BatchFetchResult.Success(
data = items,
last = items.size < batchSize,
last = items.size < request.limit,
empty = items.isEmpty(),
)
},
cursorFromItem = { item -> item.id }, // last items id becomes next cursor
)
}
private suspend fun loadItems(
config: TangemPayTxHistoryListConfig,
offset: Int,
cursor: String?,
limit: Int,
): List<VisaTxHistoryItem> {
): List<TangemPayTxHistoryItem> {
cacheRegistry.invokeOnExpire(
key = getCacheKey(userWalletId = config.userWalletId, offset = offset),
key = getCacheKey(userWalletId = config.userWalletId, cursor = cursor),
skipCache = config.refresh,
// TODO: TangemPay uncomment while BFF will be ready
// block = { fetch(userWalletId = config.userWalletId, offset = offset, pageSize = limit) },
block = { fetchMocked(userWalletId = config.userWalletId) },
block = { fetch(userWalletId = config.userWalletId, cursor = cursor, pageSize = limit) },
)
return txHistoryItemsStore.getSyncOrNull(key = config.userWalletId, offset = offset).orEmpty()
return txHistoryItemsStore.getSyncOrNull(
key = config.userWalletId,
cursor = cursor ?: INITIAL_CURSOR,
).orEmpty()
}
private fun getCacheKey(userWalletId: UserWalletId, offset: Int): String {
return "tangem_pay_tx_history_${userWalletId}_$offset"
private fun getCacheKey(userWalletId: UserWalletId, cursor: String?): String {
return "tangem_pay_tx_history_${userWalletId}_${cursor ?: INITIAL_CURSOR}"
}
private suspend fun fetch(userWalletId: UserWalletId, offset: Int, pageSize: Int) {
val response = visaApiRequestMaker.request(userWalletId = userWalletId) { authHeader, accessCodeData ->
visaApi.getTxHistory(
private suspend fun fetch(userWalletId: UserWalletId, cursor: String?, pageSize: Int) {
val response = requestPerformer.request { authHeader ->
visaApi.getTangemPayTxHistory(
authHeader = authHeader,
customerId = accessCodeData.customerId,
productInstanceId = accessCodeData.productInstanceId,
limit = pageSize,
offset = offset,
cursor = cursor,
)
}.getOrNull()
response?.let {
val items = TangemPayTxHistoryItemConverter.convertList(response.result.transactions)
txHistoryItemsStore.store(key = userWalletId, cursor = cursor ?: INITIAL_CURSOR, value = items)
}
val items = VisaTxHistoryItemConverter.convertList(response.transactions)
txHistoryItemsStore.store(key = userWalletId, offset = offset, value = items)
}
private suspend fun fetchMocked(userWalletId: UserWalletId) {
delay(2000) // initiating network request
val items = VisaTxHistoryItemConverter.convertList(MOCKED_RESPONSE.transactions)
txHistoryItemsStore.store(key = userWalletId, offset = 0, value = items)
}
companion object {
private val now = DateTime.now()
private var transactionIdCounter = 1000L
private val todayTransactions = List(2) { index ->
VisaTxHistoryResponse.Transaction(
transactionId = transactionIdCounter++,
transactionDt = now.minusHours(index + 1).minusMinutes(index * 15),
transactionStatus = "Completed", // As "Success" might map to "Completed"
transactionType = "Purchase",
billingAmount = BigDecimal("${10 + index * 5}.${20 + index * 3}"),
billingCurrencyCode = 840, // USD
transactionAmount = BigDecimal("${10 + index * 5}.${20 + index * 3}"),
transactionCurrencyCode = 840, // USD
merchantName = "Online Store ${'A' + index}",
merchantCity = "San Francisco",
merchantCountryCode = "US",
merchantCategoryCode = "5411", // Grocery Stores
authCode = "AUTH${12345 + index}",
rrn = "RRN00${100 + index}",
blockchainAmount = BigDecimal("0.001").multiply(BigDecimal(index + 1)),
blockchainCoinName = "ETH",
blockchainFee = BigDecimal("0.00005"),
requests = emptyList(),
)
}
private val yesterdayTransactions = List(4) { index ->
VisaTxHistoryResponse.Transaction(
transactionId = transactionIdCounter++,
transactionDt = now.minusDays(1).withTime(10 + index * 2, 15 * index % 60, index * 5 % 60, 0),
transactionStatus = "Completed",
transactionType = "Purchase",
billingAmount = BigDecimal("${20 + index * 7}.${10 + index * 2}"),
billingCurrencyCode = 840, // USD
transactionAmount = BigDecimal("${20 + index * 7}.${10 + index * 2}"),
transactionCurrencyCode = 840, // USD
merchantName = "Coffee Shop ${'X' + index}",
merchantCity = "Berlin",
merchantCountryCode = "DE",
merchantCategoryCode = "5812", // Restaurants
authCode = "AUTH${22345 + index}",
rrn = "RRN00${200 + index}",
blockchainAmount = BigDecimal("0.001").multiply(BigDecimal(index + 1)),
blockchainCoinName = "ETH",
blockchainFee = BigDecimal("0.00005"),
requests = emptyList(),
)
}
private val threeDaysAgoTransactions = List(10) { index ->
VisaTxHistoryResponse.Transaction(
transactionId = transactionIdCounter++,
transactionDt = now.minusDays(3).withTime(9 + index, (index * 10) % 60, (index * 20) % 60, 0),
transactionStatus = "Completed",
transactionType = "Purchase",
billingAmount = BigDecimal("${5 + index * 2}.${50 + index}"),
billingCurrencyCode = 840, // USD
transactionAmount = BigDecimal("${5 + index * 2}.${50 + index}"),
transactionCurrencyCode = 840, // USD
merchantName = "Gadget Store ${'M' + index}",
merchantCity = if (index % 2 == 0) "London" else "Paris",
merchantCountryCode = if (index % 2 == 0) "GB" else "FR",
merchantCategoryCode = "5732", // Electronic Sales
authCode = "AUTH${32345 + index}",
rrn = "RRN00${300 + index}",
blockchainAmount = BigDecimal("0.001").multiply(BigDecimal(index + 1)),
blockchainCoinName = "ETH",
blockchainFee = BigDecimal("0.00005"),
requests = emptyList(),
)
}
private val MOCKED_RESPONSE = VisaTxHistoryResponse(
cardWalletAddress = "0xYourVisaVirtualCardAddressHere",
transactions = todayTransactions + yesterdayTransactions + threeDaysAgoTransactions,
)
}
}

View file

@ -4,7 +4,9 @@ import arrow.core.Either
import arrow.core.raise.either
import com.squareup.moshi.Moshi
import com.tangem.core.error.UniversalError
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.pay.models.response.VisaErrorResponseJsonAdapter
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.visa.TangemPayStorage
@ -44,18 +46,19 @@ internal class TangemPayRequestPerformer @Inject constructor(
private val refreshTokensMutex = Mutex()
private var refreshTokensJob: Deferred<Either<UniversalError, VisaAuthTokens>>? = null
suspend fun <T : Any> request(requestBlock: suspend (header: String) -> T): Either<UniversalError, T> = either {
withContext(dispatchers.io) {
performRequest(requestBlock = requestBlock, refreshTokens = ::refreshAuthTokens).bind()
suspend fun <T : Any> request(requestBlock: suspend (header: String) -> ApiResponse<T>): Either<UniversalError, T> =
either {
withContext(dispatchers.io) {
performRequest(requestBlock = requestBlock, refreshTokens = ::refreshAuthTokens).bind()
}
}
}
private suspend fun <T : Any> performRequest(
requestBlock: suspend (header: String) -> T,
requestBlock: suspend (header: String) -> ApiResponse<T>,
refreshTokens: (suspend () -> Either<UniversalError, VisaAuthTokens>)? = null,
): Either<UniversalError, T> = either {
runCatching {
requestBlock("Bearer ${getAccessTokens().bind().accessToken}")
requestBlock("Bearer ${getAccessTokens().bind().accessToken}").getOrThrow()
}.getOrElse { error ->
when (error) {
is ApiResponseError.HttpException -> {

View file

@ -0,0 +1,51 @@
package com.tangem.data.visa.utils
import com.tangem.datasource.api.pay.models.response.TangemPayTxHistoryResponse
import com.tangem.domain.visa.model.TangemPayTxHistoryItem
import com.tangem.utils.converter.Converter
internal object TangemPayTxHistoryItemConverter :
Converter<TangemPayTxHistoryResponse.Transaction, TangemPayTxHistoryItem> {
@Suppress("CyclomaticComplexMethod")
override fun convert(value: TangemPayTxHistoryResponse.Transaction): TangemPayTxHistoryItem {
val spend = value.spend
val collateral = value.collateral
val payment = value.payment
val fee = value.fee
return TangemPayTxHistoryItem(
id = value.id,
date = when {
spend != null -> spend.postedAt
collateral != null -> collateral.postedAt
payment != null -> payment.postedAt
fee != null -> fee.postedAt
else -> null
},
amount = when {
spend != null -> spend.amount
collateral != null -> collateral.amount
payment != null -> payment.amount
fee != null -> fee.amount
else -> null
},
merchantName = when {
spend != null -> spend.merchantName
else -> null
},
status = when {
spend != null -> spend.status
payment != null -> payment.status
else -> null
},
currency = when {
spend != null -> spend.currency
collateral != null -> collateral.currency
payment != null -> payment.currency
fee != null -> fee.currency
else -> null
},
)
}
}