Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-06 18:45:24 +05:00
parent b83b9d4ef6
commit 9db2ebaef4
21 changed files with 279 additions and 20 deletions

View file

@ -293,5 +293,16 @@ internal interface TangemPayDataModule {
appCoroutineScope = appCoroutineScope,
)
}
@Provides
fun provideCreateVirtualAccountOrderUseCase(
onboardingRepository: OnboardingRepository,
pollingUseCase: StartTangemPayOrderPollingUseCase,
): CreateVirtualAccountOrderUseCase {
return CreateVirtualAccountOrderUseCase(
onboardingRepository = onboardingRepository,
pollingUseCase = pollingUseCase,
)
}
}
}

View file

@ -338,7 +338,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
fiatRate: BigDecimal?,
): PaymentAccountStatusValue {
val cardsById = cards.associateBy { it.cardId }
val tangemPayCards = productInstances.mapNotNull { productInstance ->
val tangemPayCards = cardProductInstances.mapNotNull { productInstance ->
val cardInfo = cardsById[productInstance.cardId] ?: return@mapNotNull null
val cardId = productInstance.cardId
val cardFrozenState = cardDetailsRepository.cardFrozenStateSync(cardId)

View file

@ -12,6 +12,7 @@ import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.models.request.DeeplinkValidityRequest
import com.tangem.datasource.api.pay.models.request.OrderRequest
import com.tangem.datasource.api.pay.models.request.SetTangemPayEnabledRequest
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
@ -38,7 +39,7 @@ import javax.inject.Inject
private const val VALID_STATUS = "valid"
@Suppress("LongParameterList")
@Suppress("LongParameterList", "TooManyFunctions")
internal class DefaultOnboardingRepository @Inject constructor(
private val analytics: AnalyticsEventHandler,
private val dispatcherProvider: CoroutineDispatcherProvider,
@ -113,7 +114,10 @@ internal class DefaultOnboardingRepository @Inject constructor(
): Either<VisaApiError, BankCredentials> {
return requestHelper.performRequest(userWalletId) { authHeader ->
tangemPayApi.getBankCredentials(authHeader = authHeader, productInstanceId = productInstanceId)
}.map { response -> BankCredentialsConverter.convert(response) }
}.flatMap { response ->
val result = response.result ?: return@flatMap VisaApiError.UnknownWithoutCode.left()
BankCredentialsConverter.convert(result).right()
}
}
override suspend fun isTangemPayDeactivated(userWalletId: UserWalletId): Boolean {
@ -167,6 +171,34 @@ internal class DefaultOnboardingRepository @Inject constructor(
}
}
override suspend fun createVirtualAccountOrder(
userWalletId: UserWalletId,
paymentAccountAddress: String,
): Either<VisaApiError, String> = withContext(dispatcherProvider.io) {
requestHelper.performRequest(userWalletId) { authHeader ->
tangemPayApi.createVirtualAccountOrder(
authHeader = authHeader,
body = VirtualAccountOrderRequest(
data = VirtualAccountOrderRequest.Data(depositAddress = paymentAccountAddress),
idempotencyKey = UUID.randomUUID().toString(),
),
)
}.map { response -> requireNotNull(response.result).id }
}
override suspend fun getVirtualAccountOrderId(userWalletId: UserWalletId): String? =
withContext(dispatcherProvider.io) {
val customerWalletAddress = requestHelper.getCustomerWalletAddress(userWalletId)
tangemPayStorage.getVirtualAccountOrderId(customerWalletAddress)
}
override suspend fun storeVirtualAccountOrderId(userWalletId: UserWalletId, vaOrderId: String) {
withContext(dispatcherProvider.io) {
val customerWalletAddress = requestHelper.getCustomerWalletAddress(userWalletId)
tangemPayStorage.storeVirtualAccountOrderId(customerWalletAddress, vaOrderId)
}
}
private fun getUserWallet(userWalletId: UserWalletId): UserWallet {
return userWalletsListRepository.userWallets.value?.firstOrNull { it.walletId == userWalletId }
?: error("no userWallet found")
@ -181,7 +213,7 @@ internal class DefaultOnboardingRepository @Inject constructor(
sendKycAnalytics(customerInfo.kycStatus)
// Keep the per-card frozen state up to date for every card.
customerInfo.productInstances.forEach { instance ->
customerInfo.cardProductInstances.forEach { instance ->
cardFrozenStateStore.store(key = instance.cardId, value = instance.frozenState)
}

View file

@ -4,8 +4,8 @@ import com.tangem.datasource.api.pay.models.response.BankCredentialsResponse
import com.tangem.domain.models.account.BankCredentials
import com.tangem.utils.converter.Converter
internal object BankCredentialsConverter : Converter<BankCredentialsResponse, BankCredentials> {
override fun convert(value: BankCredentialsResponse): BankCredentials {
internal object BankCredentialsConverter : Converter<BankCredentialsResponse.Result, BankCredentials> {
override fun convert(value: BankCredentialsResponse.Result): BankCredentials {
return BankCredentials(
type = value.type.orEmpty(),
beneficiaryName = value.beneficiaryName.orEmpty(),

View file

@ -57,7 +57,7 @@ internal object CustomerInfoConverter : Converter<CustomerMeResponse.Result, Cus
val name = displayName?.ifEmpty { null }
return ProductInstance(
id = id,
cardId = cardId,
cardId = cardId.orEmpty(),
frozenState = cardFrozenState,
status = status,
displayName = if (name != null) CardDisplayName(name).getOrElse { null } else null,

View file

@ -6,6 +6,7 @@ import com.tangem.datasource.api.pay.models.response.TangemPayErrorResponse
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.utils.converter.Converter
import com.tangem.utils.logging.TangemLogger
import javax.inject.Inject
import javax.inject.Singleton
@ -23,7 +24,7 @@ internal class TangemPayErrorConverter @Inject constructor(
if (value.code == ApiResponseError.HttpException.Code.UNAUTHORIZED) return VisaApiError.RefreshTokenExpired
val errorBody = value.errorBody ?: return VisaApiError.UnknownWithoutCode
return runCatching {
runCatching {
tangemPayErrorAdapter.fromJson(errorBody)?.error?.code ?: value.code.numericCode
}.map {
VisaApiError.fromBackendError(it)
@ -31,6 +32,7 @@ internal class TangemPayErrorConverter @Inject constructor(
VisaApiError.UnknownWithoutCode
}
} else {
TangemLogger.e("Not HttpException. ${value.message}", value)
VisaApiError.UnknownWithoutCode
}
}

View file

@ -24,6 +24,7 @@ internal class MockAwareOnboardingRepository @Inject constructor(
) : OnboardingRepository {
private val mockOrderIds: MutableSet<UserWalletId> = ConcurrentHashMap.newKeySet()
private val mockVaOrderIds: MutableSet<UserWalletId> = ConcurrentHashMap.newKeySet()
private val isMockMode: Boolean
get() = apiConfigsManager
@ -74,6 +75,30 @@ internal class MockAwareOnboardingRepository @Inject constructor(
return real.getOrderId(userWalletId)
}
override suspend fun createVirtualAccountOrder(
userWalletId: UserWalletId,
paymentAccountAddress: String,
): Either<VisaApiError, String> {
if (isMockMode) {
mockVaOrderIds.add(userWalletId)
return MOCK_VA_ORDER_ID.right()
}
return real.createVirtualAccountOrder(userWalletId, paymentAccountAddress)
}
override suspend fun getVirtualAccountOrderId(userWalletId: UserWalletId): String? {
if (isMockMode) return MOCK_VA_ORDER_ID.takeIf { userWalletId in mockVaOrderIds }
return real.getVirtualAccountOrderId(userWalletId)
}
override suspend fun storeVirtualAccountOrderId(userWalletId: UserWalletId, vaOrderId: String) {
if (isMockMode) {
mockVaOrderIds.add(userWalletId)
return
}
real.storeVirtualAccountOrderId(userWalletId, vaOrderId)
}
override suspend fun hasTangemPayInWallet(userWalletId: UserWalletId): Either<VisaApiError, Boolean> =
real.hasTangemPayInWallet(userWalletId)
@ -112,5 +137,6 @@ internal class MockAwareOnboardingRepository @Inject constructor(
private companion object {
const val MOCK_ORDER_ID = "mock-order-id"
const val MOCK_VA_ORDER_ID = "mock-va-order-id"
}
}

View file

@ -193,7 +193,7 @@ internal class DefaultPaymentAccountStatusFetcherTest {
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class `resolveVirtualAccountOnramp` {
inner class ResolveVirtualAccountOnramp {
@Test
fun `GIVEN feature toggle is off WHEN invoke THEN virtualAccount is null`() = runTest {

View file

@ -10,7 +10,7 @@ internal class BankCredentialsConverterTest {
@Test
fun `GIVEN full response WHEN convert THEN all fields mapped`() {
// Arrange
val response = BankCredentialsResponse(
val response = BankCredentialsResponse.Result(
type = "fiat",
beneficiaryName = "Ivan Ivanov",
beneficiaryAddress = "18, Rue Rubens 20, Paris, Ile-de-France 75013, US",
@ -39,7 +39,7 @@ internal class BankCredentialsConverterTest {
@Test
fun `GIVEN null fields WHEN convert THEN mapped to empty strings`() {
// Arrange
val response = BankCredentialsResponse(
val response = BankCredentialsResponse.Result(
type = null,
beneficiaryName = null,
beneficiaryAddress = null,