diff --git a/app/src/main/java/com/tangem/tap/data/DefaultTangemPayStorage.kt b/app/src/main/java/com/tangem/tap/data/DefaultTangemPayStorage.kt index 85108219f0..6b48f51b6f 100644 --- a/app/src/main/java/com/tangem/tap/data/DefaultTangemPayStorage.kt +++ b/app/src/main/java/com/tangem/tap/data/DefaultTangemPayStorage.kt @@ -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), diff --git a/app/src/mocked/java/com/tangem/tap/data/MockAwareTangemPayStorage.kt b/app/src/mocked/java/com/tangem/tap/data/MockAwareTangemPayStorage.kt index baea0ab1a5..cbbcb0c848 100644 --- a/app/src/mocked/java/com/tangem/tap/data/MockAwareTangemPayStorage.kt +++ b/app/src/mocked/java/com/tangem/tap/data/MockAwareTangemPayStorage.kt @@ -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) diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt index 37f8928d79..44f6df51f2 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt @@ -77,6 +77,13 @@ interface TangemPayApi { @Body body: OrderRequest, ): ApiResponse + // TODO: Doston: [REDACTED_TASK_KEY] Unify with method above + @POST("v1/order") + suspend fun createVirtualAccountOrder( + @Header("Authorization") authHeader: String, + @Body body: VirtualAccountOrderRequest, + ): ApiResponse + /** Customer offers — used to gate the issue-additional-card flow. */ @GET("v1/customer/offers") suspend fun getCustomerOffers(@Header("Authorization") authHeader: String): ApiResponse diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/VirtualAccountOrderRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/VirtualAccountOrderRequest.kt new file mode 100644 index 0000000000..7b78b64c6f --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/VirtualAccountOrderRequest.kt @@ -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", + ) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/BankCredentialsResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/BankCredentialsResponse.kt index 409984fabc..b3564320f4 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/BankCredentialsResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/BankCredentialsResponse.kt @@ -9,11 +9,16 @@ import com.squareup.moshi.JsonClass */ @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?, -) \ No newline at end of file + @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?, + @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?, + ) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt index 17de6c4cd0..21adcc2e76 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt @@ -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, diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt index ca59f2ca23..eba1fa1049 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt @@ -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}") diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayStorage.kt b/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayStorage.kt index e6b2a37527..60c91d5d63 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayStorage.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayStorage.kt @@ -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) diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt index 30297c0d46..a363e421d0 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt @@ -293,5 +293,16 @@ internal interface TangemPayDataModule { appCoroutineScope = appCoroutineScope, ) } + + @Provides + fun provideCreateVirtualAccountOrderUseCase( + onboardingRepository: OnboardingRepository, + pollingUseCase: StartTangemPayOrderPollingUseCase, + ): CreateVirtualAccountOrderUseCase { + return CreateVirtualAccountOrderUseCase( + onboardingRepository = onboardingRepository, + pollingUseCase = pollingUseCase, + ) + } } } \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt index c962f2dcdc..e77fc8bec5 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt @@ -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) diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt index 786fc9397c..10b5639f3e 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt @@ -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 { 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 = 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) } diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/util/BankCredentialsConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/util/BankCredentialsConverter.kt index 4f027487a6..2f028c7ba4 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/util/BankCredentialsConverter.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/util/BankCredentialsConverter.kt @@ -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 { - override fun convert(value: BankCredentialsResponse): BankCredentials { +internal object BankCredentialsConverter : Converter { + override fun convert(value: BankCredentialsResponse.Result): BankCredentials { return BankCredentials( type = value.type.orEmpty(), beneficiaryName = value.beneficiaryName.orEmpty(), diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/util/CustomerInfoConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/util/CustomerInfoConverter.kt index 2fd76d8480..1a2a8c5d54 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/util/CustomerInfoConverter.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/util/CustomerInfoConverter.kt @@ -57,7 +57,7 @@ internal object CustomerInfoConverter : Converter = ConcurrentHashMap.newKeySet() + private val mockVaOrderIds: MutableSet = 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 { + 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 = 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" } } \ No newline at end of file diff --git a/data/visa/src/test/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcherTest.kt b/data/visa/src/test/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcherTest.kt index e0c4bcc677..e916df8fbc 100644 --- a/data/visa/src/test/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcherTest.kt +++ b/data/visa/src/test/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcherTest.kt @@ -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 { diff --git a/data/visa/src/test/kotlin/com/tangem/data/pay/util/BankCredentialsConverterTest.kt b/data/visa/src/test/kotlin/com/tangem/data/pay/util/BankCredentialsConverterTest.kt index 6ec1cceb6c..c514bd2cfe 100644 --- a/data/visa/src/test/kotlin/com/tangem/data/pay/util/BankCredentialsConverterTest.kt +++ b/data/visa/src/test/kotlin/com/tangem/data/pay/util/BankCredentialsConverterTest.kt @@ -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, diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt index 752773bb5f..f7456bdec2 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt @@ -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 + get() = productInstances.filter { it.specificationDataType == ProductInstance.SpecificationDataType.CARD } + enum class State { NEW, ACTIVE, diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/OnboardingRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/OnboardingRepository.kt index 169c6ed172..0144c57488 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/OnboardingRepository.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/OnboardingRepository.kt @@ -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 @@ -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 + + suspend fun getVirtualAccountOrderId(userWalletId: UserWalletId): String? + + suspend fun storeVirtualAccountOrderId(userWalletId: UserWalletId, vaOrderId: String) + suspend fun hasTangemPayInWallet(userWalletId: UserWalletId): Either suspend fun checkCustomerEligibility(): List diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/CreateVirtualAccountOrderUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/CreateVirtualAccountOrderUseCase.kt new file mode 100644 index 0000000000..40829912ba --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/CreateVirtualAccountOrderUseCase.kt @@ -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 = 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, + ) + } + } +} \ No newline at end of file diff --git a/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/CreateVirtualAccountOrderUseCaseTest.kt b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/CreateVirtualAccountOrderUseCaseTest.kt new file mode 100644 index 0000000000..f285cf4bcf --- /dev/null +++ b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/CreateVirtualAccountOrderUseCaseTest.kt @@ -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()) } + } +} \ No newline at end of file