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

@ -134,6 +134,23 @@ internal class DefaultTangemPayStorage @Inject constructor(
appPreferencesStore.store(PreferencesKeys.getTangemPayOrderIdKey(customerWalletAddress), "")
}
override suspend fun storeVirtualAccountOrderId(customerWalletAddress: String, vaOrderId: String) {
appPreferencesStore.store(
key = PreferencesKeys.getTangemPayVirtualAccountOrderIdKey(customerWalletAddress),
value = vaOrderId,
)
}
override suspend fun getVirtualAccountOrderId(customerWalletAddress: String): String? {
return appPreferencesStore.getSyncOrNull(
key = PreferencesKeys.getTangemPayVirtualAccountOrderIdKey(customerWalletAddress),
).takeIf { !it.isNullOrEmpty() }
}
override suspend fun clearVirtualAccountOrderId(customerWalletAddress: String) {
appPreferencesStore.store(PreferencesKeys.getTangemPayVirtualAccountOrderIdKey(customerWalletAddress), "")
}
override suspend fun storeCheckCustomerWalletResult(userWalletId: UserWalletId, isPaeraCustomer: Boolean) {
appPreferencesStore.store(
PreferencesKeys.getTangemPayCheckCustomerByWalletId(userWalletId),

View file

@ -81,6 +81,15 @@ internal class MockAwareTangemPayStorage @Inject constructor(
override suspend fun clearOrderId(customerWalletAddress: String) =
real.clearOrderId(customerWalletAddress)
override suspend fun storeVirtualAccountOrderId(customerWalletAddress: String, vaOrderId: String) =
real.storeVirtualAccountOrderId(customerWalletAddress, vaOrderId)
override suspend fun getVirtualAccountOrderId(customerWalletAddress: String): String? =
real.getVirtualAccountOrderId(customerWalletAddress)
override suspend fun clearVirtualAccountOrderId(customerWalletAddress: String) =
real.clearVirtualAccountOrderId(customerWalletAddress)
override suspend fun storeCheckCustomerWalletResult(userWalletId: UserWalletId, isPaeraCustomer: Boolean) =
real.storeCheckCustomerWalletResult(userWalletId, isPaeraCustomer)

View file

@ -77,6 +77,13 @@ interface TangemPayApi {
@Body body: OrderRequest,
): ApiResponse<OrderResponse>
// TODO: Doston: [REDACTED_TASK_KEY] Unify with method above
@POST("v1/order")
suspend fun createVirtualAccountOrder(
@Header("Authorization") authHeader: String,
@Body body: VirtualAccountOrderRequest,
): ApiResponse<OrderResponse>
/** Customer offers — used to gate the issue-additional-card flow. */
@GET("v1/customer/offers")
suspend fun getCustomerOffers(@Header("Authorization") authHeader: String): ApiResponse<CustomerOffersResponse>

View file

@ -0,0 +1,23 @@
package com.tangem.datasource.api.pay.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Request body for creating a Virtual Account on-ramp order (VA MVP0, TWI-1638).
*
* `wallet_address` is the customer's managing (collateral-managing) wallet address; `payment_account_address`
* is the existing collateral address. Distinct from the card-issue [OrderRequest] contract.
*/
@JsonClass(generateAdapter = true)
data class VirtualAccountOrderRequest(
@Json(name = "data") val data: Data,
@Json(name = "idempotency_key") val idempotencyKey: String,
) {
@JsonClass(generateAdapter = true)
data class Data(
@Json(name = "deposit_address") val depositAddress: String,
@Json(name = "type") val type: String = "ACCOUNT_ISSUE_VIRTUAL_RAIN",
@Json(name = "specification_name") val specificationName: String = "SP_000006",
)
}

View file

@ -9,6 +9,10 @@ import com.squareup.moshi.JsonClass
*/
@JsonClass(generateAdapter = true)
data class BankCredentialsResponse(
@Json(name = "result") val result: Result?,
) {
@JsonClass(generateAdapter = true)
data class Result(
@Json(name = "type") val type: String?,
@Json(name = "beneficiary_name") val beneficiaryName: String?,
@Json(name = "beneficiary_address") val beneficiaryAddress: String?,
@ -16,4 +20,5 @@ data class BankCredentialsResponse(
@Json(name = "beneficiary_bank_address") val beneficiaryBankAddress: String?,
@Json(name = "account_number") val accountNumber: String?,
@Json(name = "routing_number") val routingNumber: String?,
)
)
}

View file

@ -27,7 +27,7 @@ data class CustomerMeResponse(
data class ProductInstance(
@Json(name = "id") val id: String,
@Json(name = "cid") val cid: String?,
@Json(name = "card_id") val cardId: String,
@Json(name = "card_id") val cardId: String?,
@Json(name = "card_wallet_address") val cardWalletAddress: String?,
@Json(name = "status") val status: Status,
@Json(name = "updated_at") val updatedAt: String,

View file

@ -189,6 +189,9 @@ object PreferencesKeys {
fun getTangemPayOrderIdKey(customerWalletAddress: String) =
stringPreferencesKey("tangem_pay_order_id_key_$customerWalletAddress")
fun getTangemPayVirtualAccountOrderIdKey(customerWalletAddress: String) =
stringPreferencesKey("tangem_pay_va_order_id_key_$customerWalletAddress")
fun getTangemPayCustomerWalletAddressKey(userWalletId: UserWalletId) =
stringPreferencesKey("tangem_pay_customer_wallet_address_key_${userWalletId.stringValue}")

View file

@ -23,6 +23,12 @@ interface TangemPayStorage {
suspend fun clearOrderId(customerWalletAddress: String)
suspend fun storeVirtualAccountOrderId(customerWalletAddress: String, vaOrderId: String)
suspend fun getVirtualAccountOrderId(customerWalletAddress: String): String?
suspend fun clearVirtualAccountOrderId(customerWalletAddress: String)
suspend fun getAddToWalletDone(customerWalletAddress: String): Boolean
suspend fun storeAddToWalletDone(customerWalletAddress: String, isDone: Boolean)

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,

View file

@ -38,6 +38,10 @@ data class CustomerInfo(
/** Transitional single-card accessor — returns the first card, or null if none. */
val cardInfo: CardInfo? get() = cards.firstOrNull()
/** Card-level product instances only (excludes the VA ACCOUNT instance). */
val cardProductInstances: List<ProductInstance>
get() = productInstances.filter { it.specificationDataType == ProductInstance.SpecificationDataType.CARD }
enum class State {
NEW,
ACTIVE,

View file

@ -8,6 +8,7 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.CustomerInfo
import com.tangem.domain.visa.error.VisaApiError
@Suppress("TooManyFunctions")
interface OnboardingRepository {
suspend fun validateDeeplink(link: String): Either<UniversalError, Boolean>
@ -30,6 +31,16 @@ interface OnboardingRepository {
suspend fun getOrderId(userWalletId: UserWalletId): String?
/** Creates a Virtual Account on-ramp order (VA MVP0, TWI-1638); returns the created order id. */
suspend fun createVirtualAccountOrder(
userWalletId: UserWalletId,
paymentAccountAddress: String,
): Either<VisaApiError, String>
suspend fun getVirtualAccountOrderId(userWalletId: UserWalletId): String?
suspend fun storeVirtualAccountOrderId(userWalletId: UserWalletId, vaOrderId: String)
suspend fun hasTangemPayInWallet(userWalletId: UserWalletId): Either<VisaApiError, Boolean>
suspend fun checkCustomerEligibility(): List<TangemPayEligibilityType>

View file

@ -0,0 +1,40 @@
package com.tangem.domain.pay.usecase
import arrow.core.Either
import arrow.core.raise.either
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.model.TangemPayOrderInfo
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.visa.error.VisaApiError
/**
* Creates the Virtual Account on-ramp order (VA MVP0, TWI-1638) and persists the returned id as `vaOrderId`.
*
* Idempotent: if an order id was already stored for the wallet, it is returned without hitting the network.
* Otherwise creates the order (`ACCOUNT_ISSUE_VIRTUAL_RAIN`) and stores the returned id.
*
* @property onboardingRepository resolves the customer wallet address, creates the order, and persists the id.
*/
class CreateVirtualAccountOrderUseCase(
private val onboardingRepository: OnboardingRepository,
private val pollingUseCase: StartTangemPayOrderPollingUseCase,
) {
suspend operator fun invoke(
userWalletId: UserWalletId,
paymentAccountAddress: String,
): Either<VisaApiError, Unit> = either {
onboardingRepository.getVirtualAccountOrderId(userWalletId)
?: run {
val vaOrderId = onboardingRepository.createVirtualAccountOrder(
userWalletId = userWalletId,
paymentAccountAddress = paymentAccountAddress,
).bind()
onboardingRepository.storeVirtualAccountOrderId(userWalletId = userWalletId, vaOrderId = vaOrderId)
pollingUseCase.invoke(
order = TangemPayOrderInfo(orderId = vaOrderId, orderStatus = OrderStatus.NEW),
userWalletId = userWalletId,
)
}
}
}

View file

@ -0,0 +1,63 @@
package com.tangem.domain.pay.usecase
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.visa.error.VisaApiError
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
internal class CreateVirtualAccountOrderUseCaseTest {
private val onboardingRepository: OnboardingRepository = mockk(relaxUnitFun = true)
private val pollingUseCase: StartTangemPayOrderPollingUseCase = mockk(relaxed = true)
private val useCase = CreateVirtualAccountOrderUseCase(onboardingRepository, pollingUseCase)
private val userWalletId = UserWalletId("1234567890ABCDEF")
private val paymentAccountAddress = "0xcollateral"
@Test
fun `GIVEN stored va order id WHEN invoke THEN skips creation and polling`() = runTest {
coEvery { onboardingRepository.getVirtualAccountOrderId(userWalletId) } returns "existing-id"
val result = useCase(userWalletId, paymentAccountAddress)
assertThat(result.isRight()).isTrue()
coVerify(exactly = 0) { onboardingRepository.createVirtualAccountOrder(any(), any()) }
coVerify(exactly = 0) { onboardingRepository.storeVirtualAccountOrderId(any(), any()) }
coVerify(exactly = 0) { pollingUseCase.invoke(any(), any()) }
}
@Test
fun `GIVEN no stored id and create succeeds WHEN invoke THEN stores id and starts polling`() = runTest {
coEvery { onboardingRepository.getVirtualAccountOrderId(userWalletId) } returns null
coEvery {
onboardingRepository.createVirtualAccountOrder(userWalletId, paymentAccountAddress)
} returns "new-id".right()
val result = useCase(userWalletId, paymentAccountAddress)
assertThat(result.isRight()).isTrue()
coVerify(exactly = 1) { onboardingRepository.storeVirtualAccountOrderId(userWalletId, "new-id") }
coVerify(exactly = 1) { pollingUseCase.invoke(any(), userWalletId) }
}
@Test
fun `GIVEN no stored id and create fails WHEN invoke THEN returns error and does not store or poll`() = runTest {
coEvery { onboardingRepository.getVirtualAccountOrderId(userWalletId) } returns null
coEvery {
onboardingRepository.createVirtualAccountOrder(userWalletId, paymentAccountAddress)
} returns VisaApiError.Unspecified.left()
val result = useCase(userWalletId, paymentAccountAddress)
assertThat(result.leftOrNull()).isEqualTo(VisaApiError.Unspecified)
coVerify(exactly = 0) { onboardingRepository.storeVirtualAccountOrderId(any(), any()) }
coVerify(exactly = 0) { pollingUseCase.invoke(any(), any()) }
}
}