Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-29 18:24:12 +05:00
parent 4faf6e9cb0
commit e06dc1297d
16 changed files with 287 additions and 8 deletions

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.VirtualAccountOnramp
import com.tangem.domain.models.pay.*
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayCurrencyFactory
@ -118,6 +119,7 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
)
},
error = null,
virtualAccount = VirtualAccountOnramp.None,
)
is PaymentAccountStatusValueDM.UnderReview -> PaymentAccountStatusValue.UnderReview(
source = StatusSource.CACHE,

View file

@ -4,21 +4,16 @@ import arrow.core.Either
import com.tangem.data.pay.store.PaymentAccountStatusesStore
import com.tangem.domain.core.utils.catchOn
import com.tangem.domain.models.StatusSource
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.account.hasAccountData
import com.tangem.domain.models.account.*
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.TangemPayCardLimitData
import com.tangem.domain.models.pay.TangemPayCardState
import com.tangem.domain.models.pay.*
import com.tangem.domain.models.quote.QuoteStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayCurrencyFactory
import com.tangem.domain.pay.TangemPayEligibilityManager
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
import com.tangem.domain.pay.model.CustomerInfo
import com.tangem.domain.pay.model.CustomerInfo.ProductInstance.SpecificationDataType
import com.tangem.domain.pay.model.OrderData
import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.model.TangemPayEntryPoint
@ -26,6 +21,7 @@ import com.tangem.domain.pay.repository.*
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.virtualaccount.VirtualAccountFeatureToggles
import com.tangem.security.DeviceSecurityInfoProvider
import com.tangem.security.isSecurityExposed
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -66,6 +62,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
private val closeCardRepository: TangemPayCloseCardRepository,
private val cardDetailsRepository: TangemPayCardDetailsRepository,
private val issueCardRepository: TangemPayIssueCardRepository,
private val virtualAccountFeatureToggles: VirtualAccountFeatureToggles,
) : PaymentAccountStatusFetcher {
private val logger = TangemLogger.withTag(TAG)
@ -382,6 +379,8 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
// the previously shown order and append newly seen cards at the end.
val orderedCards = tangemPayCards.stableOrder(previousRealCardOrder(userWalletId))
val virtualAccount = resolveVirtualAccountOnramp(userWalletId)
return PaymentAccountStatusValue.Loaded(
source = StatusSource.ACTUAL,
customerId = customerId,
@ -395,6 +394,50 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
availableForWithdrawal = availableForWithdrawal.orZero(),
),
error = null,
virtualAccount = virtualAccount,
)
}
/**
* Resolves the Virtual Account on-ramp dimension (VA MVP0, TWI-1638). Gated by the feature toggle.
* If a product instance with [SpecificationDataType.ACCOUNT] exists, eagerly fetches its bank credentials
* ([VirtualAccountOnramp.Available]); otherwise surfaces [VirtualAccountOnramp.Eligible] when the wallet has
* the `VISA_VIRTUAL_ACCOUNT` eligibility channel (fetched fresh via the user token), else
* [VirtualAccountOnramp.None].
*/
private suspend fun CustomerInfo.resolveVirtualAccountOnramp(userWalletId: UserWalletId): VirtualAccountOnramp {
if (!virtualAccountFeatureToggles.isVaMvp0Enabled) return VirtualAccountOnramp.None
val accountInstance = productInstances.firstOrNull {
it.specificationDataType == SpecificationDataType.ACCOUNT
}
if (accountInstance != null) {
return onboardingRepository.getBankCredentials(userWalletId, accountInstance.id).fold(
ifLeft = { error ->
logger.e("getBankCredentials failed for ${accountInstance.id}: $error")
VirtualAccountOnramp.None
},
ifRight = { credentials ->
VirtualAccountOnramp.Available(
productInstanceId = accountInstance.id,
bankCredentials = credentials,
)
},
)
}
return onboardingRepository.fetchCustomerEligibility(userWalletId).fold(
ifLeft = { error ->
logger.e("fetchCustomerEligibility failed for $userWalletId: $error")
VirtualAccountOnramp.None
},
ifRight = { channels ->
if (channels.contains(TangemPayEligibilityType.VISA_VIRTUAL_ACCOUNT)) {
VirtualAccountOnramp.Eligible
} else {
VirtualAccountOnramp.None
}
},
)
}

View file

@ -6,6 +6,7 @@ import arrow.core.left
import arrow.core.right
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.data.pay.store.PaymentAccountStatusesStore
import com.tangem.data.pay.util.BankCredentialsConverter
import com.tangem.data.pay.util.CustomerInfoConverter
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.models.request.DeeplinkValidityRequest
@ -18,6 +19,7 @@ import com.tangem.datasource.local.visa.TangemPayStorage
import com.tangem.domain.common.wallets.UserWalletsListRepository
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.kyc.KycStatus
import com.tangem.domain.models.pay.TangemPayEligibilityType
@ -105,6 +107,15 @@ internal class DefaultOnboardingRepository @Inject constructor(
}
}
override suspend fun getBankCredentials(
userWalletId: UserWalletId,
productInstanceId: String,
): Either<VisaApiError, BankCredentials> {
return requestHelper.performRequest(userWalletId) { authHeader ->
tangemPayApi.getBankCredentials(authHeader = authHeader, productInstanceId = productInstanceId)
}.map { response -> BankCredentialsConverter.convert(response) }
}
override suspend fun isTangemPayDeactivated(userWalletId: UserWalletId): Boolean {
return tangemPayStorage.isTangemPayDeactivated(userWalletId)
}
@ -226,6 +237,16 @@ internal class DefaultOnboardingRepository @Inject constructor(
return tangemPayStorage.getTangemPayEligibility().map(TangemPayEligibilityType::fromString)
}
override suspend fun fetchCustomerEligibility(
userWalletId: UserWalletId,
): Either<VisaApiError, List<TangemPayEligibilityType>> {
return requestHelper.performRequest(userWalletId) { authHeader ->
tangemPayApi.getUserEligibilityChannels(authHeader)
}.map { response ->
response.result.channels.map(TangemPayEligibilityType::fromString)
}
}
override suspend fun getHideMainOnboardingBanner(userWalletId: UserWalletId): Boolean {
return tangemPayStorage.getHideMainOnboardingBanner(userWalletId)
}

View file

@ -0,0 +1,19 @@
package com.tangem.data.pay.util
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 {
return BankCredentials(
type = value.type.orEmpty(),
beneficiaryName = value.beneficiaryName.orEmpty(),
beneficiaryAddress = value.beneficiaryAddress.orEmpty(),
beneficiaryBankName = value.beneficiaryBankName.orEmpty(),
beneficiaryBankAddress = value.beneficiaryBankAddress.orEmpty(),
accountNumber = value.accountNumber.orEmpty(),
routingNumber = value.routingNumber.orEmpty(),
)
}
}

View file

@ -14,6 +14,7 @@ import com.tangem.domain.models.pay.TangemPayCardLimitPeriod
import com.tangem.domain.pay.model.CustomerInfo
import com.tangem.domain.pay.model.CustomerInfo.CardInfo
import com.tangem.domain.pay.model.CustomerInfo.ProductInstance
import com.tangem.domain.pay.model.CustomerInfo.ProductInstance.SpecificationDataType
import com.tangem.domain.pay.model.CustomerInfo.ProductInstance.Status
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.orZero
@ -62,6 +63,7 @@ internal object CustomerInfoConverter : Converter<CustomerMeResponse.Result, Cus
displayName = if (name != null) CardDisplayName(name).getOrElse { null } else null,
actualCardLimit = actualCardLimit?.parseCardLimit(),
adminCardLimit = adminCardLimit?.parseCardLimit(),
specificationDataType = specificationDataType.toDomain(),
)
}
@ -108,4 +110,10 @@ internal object CustomerInfoConverter : Converter<CustomerMeResponse.Result, Cus
CustomerMeResponse.ProductInstance.Status.CANCELED -> Status.CANCELED
CustomerMeResponse.ProductInstance.Status.UNKNOWN -> Status.UNKNOWN
}
private fun CustomerMeResponse.ProductInstance.SpecificationDataType.toDomain(): SpecificationDataType =
when (this) {
CustomerMeResponse.ProductInstance.SpecificationDataType.ACCOUNT -> SpecificationDataType.ACCOUNT
CustomerMeResponse.ProductInstance.SpecificationDataType.CARD -> SpecificationDataType.CARD
}
}

View file

@ -6,6 +6,7 @@ import com.tangem.core.error.UniversalError
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.account.BankCredentials
import com.tangem.domain.models.pay.TangemPayEligibilityType
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.CustomerInfo
@ -47,6 +48,11 @@ internal class MockAwareOnboardingRepository @Inject constructor(
override suspend fun getCustomerInfo(userWalletId: UserWalletId): Either<VisaApiError, CustomerInfo> =
real.getCustomerInfo(userWalletId)
override suspend fun getBankCredentials(
userWalletId: UserWalletId,
productInstanceId: String,
): Either<VisaApiError, BankCredentials> = real.getBankCredentials(userWalletId, productInstanceId)
override suspend fun createOrder(userWalletId: UserWalletId): Either<VisaApiError, String> {
if (isMockMode) {
mockOrderIds.add(userWalletId)
@ -77,6 +83,10 @@ internal class MockAwareOnboardingRepository @Inject constructor(
override suspend fun getCustomerEligibility(): List<TangemPayEligibilityType> =
real.getCustomerEligibility()
override suspend fun fetchCustomerEligibility(
userWalletId: UserWalletId,
): Either<VisaApiError, List<TangemPayEligibilityType>> = real.fetchCustomerEligibility(userWalletId)
override fun getSavedCustomerInfo(userWalletId: UserWalletId): CustomerInfo? =
real.getSavedCustomerInfo(userWalletId)

View file

@ -0,0 +1,67 @@
package com.tangem.data.pay.util
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.api.pay.models.response.BankCredentialsResponse
import com.tangem.domain.models.account.BankCredentials
import org.junit.jupiter.api.Test
internal class BankCredentialsConverterTest {
@Test
fun `GIVEN full response WHEN convert THEN all fields mapped`() {
// Arrange
val response = BankCredentialsResponse(
type = "fiat",
beneficiaryName = "Ivan Ivanov",
beneficiaryAddress = "18, Rue Rubens 20, Paris, Ile-de-France 75013, US",
beneficiaryBankName = "SSB BANK",
beneficiaryBankAddress = "8700 Perry Highway, Pittsburgh, PA 15237, US",
accountNumber = "707613210122",
routingNumber = "043087080",
)
// Act
val actual = BankCredentialsConverter.convert(response)
// Assert
val expected = BankCredentials(
type = "fiat",
beneficiaryName = "Ivan Ivanov",
beneficiaryAddress = "18, Rue Rubens 20, Paris, Ile-de-France 75013, US",
beneficiaryBankName = "SSB BANK",
beneficiaryBankAddress = "8700 Perry Highway, Pittsburgh, PA 15237, US",
accountNumber = "707613210122",
routingNumber = "043087080",
)
assertThat(actual).isEqualTo(expected)
}
@Test
fun `GIVEN null fields WHEN convert THEN mapped to empty strings`() {
// Arrange
val response = BankCredentialsResponse(
type = null,
beneficiaryName = null,
beneficiaryAddress = null,
beneficiaryBankName = null,
beneficiaryBankAddress = null,
accountNumber = null,
routingNumber = null,
)
// Act
val actual = BankCredentialsConverter.convert(response)
// Assert
val expected = BankCredentials(
type = "",
beneficiaryName = "",
beneficiaryAddress = "",
beneficiaryBankName = "",
beneficiaryBankAddress = "",
accountNumber = "",
routingNumber = "",
)
assertThat(actual).isEqualTo(expected)
}
}