Updated on 2026-08-14
This commit is contained in:
parent
6d3a1d6e89
commit
99a8c6623b
15 changed files with 328 additions and 171 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 item’s 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 -> {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue