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

@ -23,6 +23,13 @@ interface TangemPayApi {
@GET("v1/customer/me")
suspend fun getCustomerMe(@Header("Authorization") authHeader: String): ApiResponse<CustomerMeResponse>
/** Fiat bank requisites for the Virtual Account on-ramp (VA MVP0, TWI-1638). */
@GET("v1/account/bank-credentials/{product_instance_id}")
suspend fun getBankCredentials(
@Header("Authorization") authHeader: String,
@Path("product_instance_id") productInstanceId: String,
): ApiResponse<BankCredentialsResponse>
@GET("v1/customer/wallets/{customer_wallet_id}")
suspend fun checkCustomerWalletId(
@Path("customer_wallet_id") customerWalletId: String,
@ -40,6 +47,12 @@ interface TangemPayApi {
@GET("v1/eligibility/channels")
suspend fun getEligibilityChannels(): ApiResponse<TangemPayEligibilityChannels>
/** Eligibility channels fetched with the user (customer-wallet) token (VA MVP0, TWI-1638). */
@GET("v1/eligibility/channels")
suspend fun getUserEligibilityChannels(
@Header("Authorization") authHeader: String,
): ApiResponse<TangemPayEligibilityChannels>
@GET("v1/order/{order_id}")
suspend fun getOrder(
@Header("Authorization") authHeader: String,

View file

@ -0,0 +1,19 @@
package com.tangem.datasource.api.pay.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Response of `bff-v2/v1/account/bank-credentials/{product_instance_id}` fiat bank requisites for the
* Virtual Account on-ramp (VA MVP0, TWI-1638).
*/
@JsonClass(generateAdapter = true)
data class BankCredentialsResponse(
@Json(name = "type") val type: String?,
@Json(name = "beneficiary_name") val beneficiaryName: String?,
@Json(name = "beneficiary_address") val beneficiaryAddress: String?,
@Json(name = "beneficiary_bank_name") val beneficiaryBankName: String?,
@Json(name = "beneficiary_bank_address") val beneficiaryBankAddress: String?,
@Json(name = "account_number") val accountNumber: String?,
@Json(name = "routing_number") val routingNumber: String?,
)

View file

@ -48,6 +48,7 @@ dependencies {
implementation(projects.domain.quotes)
implementation(projects.domain.common)
implementation(projects.features.swap.domain)
implementation(projects.features.virtualAccounts.details.api)
/** Project - Utils */

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)
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.domain.models.account
import kotlinx.serialization.Serializable
/**
* Bank (fiat) credentials for a Virtual Account on-ramp the wire/ACH requisites a user transfers funds to.
*
* Returned by `bff-v2/v1/account/bank-credentials/{product_instance_id}`. Sensitive data kept transient
* (never persisted in the local payment-account cache).
*/
@Serializable
data class BankCredentials(
val type: String,
val beneficiaryName: String,
val beneficiaryAddress: String,
val beneficiaryBankName: String,
val beneficiaryBankAddress: String,
val accountNumber: String,
val routingNumber: String,
)

View file

@ -149,6 +149,8 @@ sealed class PaymentAccountStatusValue {
* [totalFiatBalance] resolves to [TotalFiatBalance.Failed].
* @property error Transient error overlaid on top of cached data when a refresh fails
* (see [copySealed]), or `null` when the status is up to date. Not persisted.
* @property virtualAccount Virtual Account (Visa on-ramp) availability VA MVP0 (TWI-1638).
* Transient: not persisted in the local cache.
*/
@Serializable
data class Loaded(
@ -160,6 +162,7 @@ sealed class PaymentAccountStatusValue {
val cards: List<TangemPayCard>,
val fiatRate: SerializedBigDecimal?,
val error: Error?,
val virtualAccount: VirtualAccountOnramp,
) : PaymentAccountStatusValue() {
val cryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus(
currency = cryptoCurrency,

View file

@ -0,0 +1,28 @@
package com.tangem.domain.models.account
import kotlinx.serialization.Serializable
/**
* Virtual Account (Visa on-ramp) availability for a payment account VA MVP0 (TWI-1638).
*
* Computed in the payment-account fetcher and surfaced on [PaymentAccountStatusValue.Loaded].
* Transient: [Available.bankCredentials] is never persisted in the local cache.
*/
@Serializable
sealed interface VirtualAccountOnramp {
/** On-ramp not applicable: feature toggle off, or wallet not eligible. */
@Serializable
data object None : VirtualAccountOnramp
/** No VA product instance yet, but the wallet is eligible to add funds (channel `VISA_VIRTUAL_ACCOUNT`). */
@Serializable
data object Eligible : VirtualAccountOnramp
/** VA product instance exists; [bankCredentials] are the fiat requisites for the bank-transfer top-up. */
@Serializable
data class Available(
val productInstanceId: String,
val bankCredentials: BankCredentials,
) : VirtualAccountOnramp
}

View file

@ -10,6 +10,8 @@ enum class TangemPayEligibilityType {
DETAILS_VIRTUAL_ACCOUNT,
DEEPLINK_VIRTUAL_ACCOUNT,
VISA_VIRTUAL_ACCOUNT,
UNKNOWN,
;
@ -21,6 +23,7 @@ enum class TangemPayEligibilityType {
"BANNER_VIRTUAL_ACCOUNT" -> BANNER_VIRTUAL_ACCOUNT
"DETAILS_VIRTUAL_ACCOUNT" -> DETAILS_VIRTUAL_ACCOUNT
"DEEPLINK_VIRTUAL_ACCOUNT" -> DEEPLINK_VIRTUAL_ACCOUNT
"VISA_VIRTUAL_ACCOUNT" -> VISA_VIRTUAL_ACCOUNT
else -> UNKNOWN
}
}

View file

@ -67,6 +67,7 @@ data class CustomerInfo(
val actualCardLimit: TangemPayCardLimit?,
val adminCardLimit: TangemPayCardLimit?,
val status: Status,
val specificationDataType: SpecificationDataType,
) {
enum class Status {
NEW,
@ -82,6 +83,12 @@ data class CustomerInfo(
CANCELED,
UNKNOWN,
}
/** `ACCOUNT` marks a Virtual Account instance (vs. a `CARD`); used by VA MVP0 (TWI-1638). */
enum class SpecificationDataType {
ACCOUNT,
CARD,
}
}
data class CardInfo(

View file

@ -2,6 +2,7 @@ package com.tangem.domain.pay.repository
import arrow.core.Either
import com.tangem.core.error.UniversalError
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
@ -17,6 +18,12 @@ interface OnboardingRepository {
suspend fun getCustomerInfo(userWalletId: UserWalletId): Either<VisaApiError, CustomerInfo>
/** Fiat bank requisites for the wallet's Virtual Account on-ramp instance (VA MVP0, TWI-1638). */
suspend fun getBankCredentials(
userWalletId: UserWalletId,
productInstanceId: String,
): Either<VisaApiError, BankCredentials>
suspend fun createOrder(userWalletId: UserWalletId): Either<VisaApiError, String>
suspend fun clearOrderId(userWalletId: UserWalletId)
@ -28,6 +35,14 @@ interface OnboardingRepository {
suspend fun checkCustomerEligibility(): List<TangemPayEligibilityType>
suspend fun getCustomerEligibility(): List<TangemPayEligibilityType>
/**
* Fetches eligibility channels fresh via the user token (always hits the network, no cache read/write).
* Differs from [checkCustomerEligibility] (static token, caches) and [getCustomerEligibility] (cache only).
*/
suspend fun fetchCustomerEligibility(
userWalletId: UserWalletId,
): Either<VisaApiError, List<TangemPayEligibilityType>>
fun getSavedCustomerInfo(userWalletId: UserWalletId): CustomerInfo?
suspend fun getHideMainOnboardingBanner(userWalletId: UserWalletId): Boolean