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 049f062c03..4c3b7dae3d 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 @@ -181,13 +181,14 @@ internal class DefaultOnboardingRepository @Inject constructor( override suspend fun createVirtualAccountOrder( userWalletId: UserWalletId, paymentAccountAddress: String, + idempotencyKey: String, ): Either = withContext(dispatcherProvider.io) { requestHelper.performRequest(userWalletId) { authHeader -> tangemPayApi.createVirtualAccountOrder( authHeader = authHeader, body = VirtualAccountOrderRequest( data = VirtualAccountOrderRequest.Data(depositAddress = paymentAccountAddress), - idempotencyKey = UUID.randomUUID().toString(), + idempotencyKey = idempotencyKey, ), ) }.map { response -> requireNotNull(response.result).id } 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 0144c57488..402d5a6250 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 @@ -35,6 +35,7 @@ interface OnboardingRepository { suspend fun createVirtualAccountOrder( userWalletId: UserWalletId, paymentAccountAddress: String, + idempotencyKey: String, ): Either suspend fun getVirtualAccountOrderId(userWalletId: UserWalletId): String? 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 index 40829912ba..d2c74028d8 100644 --- 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 @@ -7,6 +7,7 @@ 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 +import java.util.UUID /** * Creates the Virtual Account on-ramp order (VA MVP0, TWI-1638) and persists the returned id as `vaOrderId`. @@ -29,6 +30,7 @@ class CreateVirtualAccountOrderUseCase( val vaOrderId = onboardingRepository.createVirtualAccountOrder( userWalletId = userWalletId, paymentAccountAddress = paymentAccountAddress, + idempotencyKey = UUID.randomUUID().toString(), ).bind() onboardingRepository.storeVirtualAccountOrderId(userWalletId = userWalletId, vaOrderId = vaOrderId) pollingUseCase.invoke( 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 index f285cf4bcf..c300a5e09e 100644 --- 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 @@ -28,7 +28,7 @@ internal class CreateVirtualAccountOrderUseCaseTest { val result = useCase(userWalletId, paymentAccountAddress) assertThat(result.isRight()).isTrue() - coVerify(exactly = 0) { onboardingRepository.createVirtualAccountOrder(any(), any()) } + coVerify(exactly = 0) { onboardingRepository.createVirtualAccountOrder(any(), any(), any()) } coVerify(exactly = 0) { onboardingRepository.storeVirtualAccountOrderId(any(), any()) } coVerify(exactly = 0) { pollingUseCase.invoke(any(), any()) } } @@ -37,7 +37,7 @@ internal class CreateVirtualAccountOrderUseCaseTest { 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) + onboardingRepository.createVirtualAccountOrder(userWalletId, paymentAccountAddress, any()) } returns "new-id".right() val result = useCase(userWalletId, paymentAccountAddress) @@ -51,7 +51,7 @@ internal class CreateVirtualAccountOrderUseCaseTest { 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) + onboardingRepository.createVirtualAccountOrder(userWalletId, paymentAccountAddress, any()) } returns VisaApiError.Unspecified.left() val result = useCase(userWalletId, paymentAccountAddress) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt index f4df1c4318..68c7090dcf 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt @@ -101,6 +101,10 @@ internal class DefaultTangemPayDetailsContainerComponent @AssistedInject constru userWalletId = params.initialStatus.userWalletId, ), ) + TangemPayAccountDetailsInnerRoute.VirtualAccountDepositSuccess -> + TangemPayVirtualAccountDepositSuccessComponent( + appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), + ) } private fun onChildBack() { diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageComponent.kt index 189fe07e48..14d96d6643 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageComponent.kt @@ -112,6 +112,10 @@ internal class TangemPayCardPageComponent @AssistedInject constructor( appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), isRedesignEnabled = tangemPayFeatureToggles.isRedesignEnabled, ) + TangemPayCardDetailsInnerRoute.VirtualAccountDepositSuccess -> + TangemPayVirtualAccountDepositSuccessComponent( + appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), + ) } private fun onChildBack() { diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageScreenComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageScreenComponent.kt index 19541d7568..be3f9eae88 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageScreenComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageScreenComponent.kt @@ -63,6 +63,7 @@ internal class TangemPayCardPageScreenComponent( } } + @Suppress("LongMethod") private fun bottomSheetChild( navigation: TangemPayCardNavigation, componentContext: ComponentContext, @@ -109,8 +110,11 @@ internal class TangemPayCardPageScreenComponent( appComponentContext = context, params = TangemPayVirtualAccountDepositComponent.Params( virtualAccountOnramp = navigation.virtualAccountOnramp, + userWalletId = navigation.userWalletId, + paymentAccountAddress = navigation.paymentAccountAddress, onDismiss = model.bottomSheetNavigation::dismiss, onShowDetails = model::onShowVirtualAccountRequisites, + onOrderCreated = model::onVirtualAccountOrderCreated, ), ) is TangemPayCardNavigation.VirtualAccountRequisites -> virtualAccountAddFundsComponentFactory.create( diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt index 2770f19644..a717de6349 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt @@ -116,6 +116,7 @@ internal class TangemPayDetailsComponent( } } + @Suppress("LongMethod") private fun bottomSheetChild( navigation: TangemPayDetailsNavigation, componentContext: ComponentContext, @@ -155,8 +156,11 @@ internal class TangemPayDetailsComponent( appComponentContext = context, params = TangemPayVirtualAccountDepositComponent.Params( virtualAccountOnramp = navigation.virtualAccountOnramp, + userWalletId = navigation.userWalletId, + paymentAccountAddress = navigation.paymentAccountAddress, onDismiss = model.bottomSheetNavigation::dismiss, onShowDetails = model::onShowVirtualAccountRequisites, + onOrderCreated = model::onVirtualAccountOrderCreated, ), ) is TangemPayDetailsNavigation.VirtualAccountRequisites -> virtualAccountAddFundsComponentFactory.create( diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayVirtualAccountDepositComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayVirtualAccountDepositComponent.kt index d7ab98fe71..0e7bb6baa7 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayVirtualAccountDepositComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayVirtualAccountDepositComponent.kt @@ -1,10 +1,13 @@ package com.tangem.features.tangempay.components import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.domain.models.account.VirtualAccountOnramp +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.features.tangempay.model.TangemPayVirtualAccountDepositModel import com.tangem.features.tangempay.ui.TangemPayVirtualAccountDepositBottomSheet @@ -25,12 +28,16 @@ internal class TangemPayVirtualAccountDepositComponent( @Composable override fun BottomSheet() { - TangemPayVirtualAccountDepositBottomSheet(state = model.uiState) + val state by model.uiState.collectAsStateWithLifecycle() + TangemPayVirtualAccountDepositBottomSheet(state = state) } data class Params( val virtualAccountOnramp: VirtualAccountOnramp, + val userWalletId: UserWalletId, + val paymentAccountAddress: String, val onDismiss: () -> Unit, val onShowDetails: (VirtualAccountOnramp.Available) -> Unit, + val onOrderCreated: () -> Unit, ) } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayVirtualAccountDepositSuccessComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayVirtualAccountDepositSuccessComponent.kt new file mode 100644 index 0000000000..4b1ce2d19b --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayVirtualAccountDepositSuccessComponent.kt @@ -0,0 +1,35 @@ +package com.tangem.features.tangempay.components + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.ui.components.TangemPaySuccessScreenWrapper + +/** + + * "Preparing your banking details". Close pops back to the previous screen. + */ +internal class TangemPayVirtualAccountDepositSuccessComponent( + private val appComponentContext: AppComponentContext, +) : AppComponentContext by appComponentContext, ComposableContentComponent { + + @Composable + override fun Content(modifier: Modifier) { + BackHandler(onBack = ::onClose) + TangemPaySuccessScreenWrapper( + modifier = modifier, + title = resourceReference(R.string.tangempay_bank_transfer_success_title), + subtitle = resourceReference(R.string.tangempay_bank_transfer_success_subtitle), + buttonText = resourceReference(R.string.common_close), + onButtonClick = ::onClose, + ) + } + + private fun onClose() { + router.pop() + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardNavigation.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardNavigation.kt index 76de57b42b..74c99dd180 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardNavigation.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardNavigation.kt @@ -38,6 +38,8 @@ internal sealed class TangemPayCardNavigation { @Serializable data class VirtualAccountDeposit( val virtualAccountOnramp: VirtualAccountOnramp, + val userWalletId: UserWalletId, + val paymentAccountAddress: String, ) : TangemPayCardNavigation() @Serializable diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsNavigation.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsNavigation.kt index 5e1f43d8ed..82b020d3f7 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsNavigation.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsNavigation.kt @@ -29,6 +29,8 @@ internal sealed class TangemPayDetailsNavigation { @Serializable data class VirtualAccountDeposit( val virtualAccountOnramp: VirtualAccountOnramp, + val userWalletId: UserWalletId, + val paymentAccountAddress: String, ) : TangemPayDetailsNavigation() @Serializable diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayVirtualAccountDepositUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayVirtualAccountDepositUM.kt index e6c98fa99d..069d11a08f 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayVirtualAccountDepositUM.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayVirtualAccountDepositUM.kt @@ -13,6 +13,7 @@ import kotlinx.collections.immutable.ImmutableList internal data class TangemPayVirtualAccountDepositUM( val fees: ImmutableList, val shouldShowTermsAndConditions: Boolean, + val isLoading: Boolean, val onShowDetailsClick: () -> Unit, val onDismiss: () -> Unit, val onTermsClick: () -> Unit, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt index 05e00caa00..3911bfae65 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt @@ -487,9 +487,21 @@ internal class TangemPayCardPageModel @Inject constructor( } override fun onClickBankTransfer() { - val onramp = currentStatus.value.ifLoadedOrNull { it.virtualAccount } ?: return + val loaded = currentStatus.value.ifLoadedOrNull { it } ?: return + val onramp = loaded.virtualAccount ?: return bottomSheetNavigation.dismiss() - bottomSheetNavigation.activate(TangemPayCardNavigation.VirtualAccountDeposit(onramp)) + bottomSheetNavigation.activate( + TangemPayCardNavigation.VirtualAccountDeposit( + virtualAccountOnramp = onramp, + userWalletId = userWalletId, + paymentAccountAddress = loaded.balance.cryptoBalance.depositAddress, + ), + ) + } + + fun onVirtualAccountOrderCreated() { + bottomSheetNavigation.dismiss() + router.push(TangemPayCardDetailsInnerRoute.VirtualAccountDepositSuccess) } fun onShowVirtualAccountRequisites(onramp: VirtualAccountOnramp.Available) { diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt index 3fb9c7906e..df67a00b8a 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt @@ -354,9 +354,21 @@ internal class TangemPayDetailsModel @Inject constructor( } override fun onClickBankTransfer() { - val onramp = currentStatus.value.ifLoadedOrNull { it.virtualAccount } ?: return + val loaded = currentStatus.value.ifLoadedOrNull { it } ?: return + val onramp = loaded.virtualAccount ?: return bottomSheetNavigation.dismiss() - bottomSheetNavigation.activate(TangemPayDetailsNavigation.VirtualAccountDeposit(onramp)) + bottomSheetNavigation.activate( + TangemPayDetailsNavigation.VirtualAccountDeposit( + virtualAccountOnramp = onramp, + userWalletId = userWalletId, + paymentAccountAddress = loaded.balance.cryptoBalance.depositAddress, + ), + ) + } + + fun onVirtualAccountOrderCreated() { + bottomSheetNavigation.dismiss() + router.push(TangemPayAccountDetailsInnerRoute.VirtualAccountDepositSuccess) } fun onShowVirtualAccountRequisites(onramp: VirtualAccountOnramp.Available) { diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayVirtualAccountDepositModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayVirtualAccountDepositModel.kt index cc9bb49005..d11441c9b5 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayVirtualAccountDepositModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayVirtualAccountDepositModel.kt @@ -4,13 +4,21 @@ import androidx.compose.runtime.Stable import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.navigation.url.UrlOpener -import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.message.ToastMessage import com.tangem.domain.models.account.VirtualAccountOnramp +import com.tangem.domain.pay.usecase.CreateVirtualAccountOrderUseCase import com.tangem.features.tangempay.components.TangemPayVirtualAccountDepositComponent +import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.entity.TangemPayVirtualAccountDepositUM import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import javax.inject.Inject @Stable @@ -19,21 +27,33 @@ internal class TangemPayVirtualAccountDepositModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, private val urlOpener: UrlOpener, + private val uiMessageSender: UiMessageSender, + private val createVirtualAccountOrderUseCase: CreateVirtualAccountOrderUseCase, ) : Model() { private val params = paramsContainer.require() - val uiState: TangemPayVirtualAccountDepositUM = TangemPayVirtualAccountDepositUM( - fees = persistentListOf( - TangemPayVirtualAccountDepositUM.FeeRow(title = stringReference("ACH"), value = "$1"), - TangemPayVirtualAccountDepositUM.FeeRow(title = stringReference("FedWire"), value = "$11"), - ), - shouldShowTermsAndConditions = params.virtualAccountOnramp is VirtualAccountOnramp.Eligible, - onShowDetailsClick = ::onShowDetailsClick, - onDismiss = ::onDismiss, - onTermsClick = { urlOpener.openUrl(TERMS_OF_USE_URL) }, - onPrivacyClick = { urlOpener.openUrl(PRIVACY_POLICY_URL) }, - ) + val uiState: StateFlow + field = MutableStateFlow( + TangemPayVirtualAccountDepositUM( + fees = persistentListOf( + TangemPayVirtualAccountDepositUM.FeeRow( + title = resourceReference(R.string.tangempay_bank_transfer_fee_ach), + value = "$1", + ), + TangemPayVirtualAccountDepositUM.FeeRow( + title = resourceReference(R.string.tangempay_bank_transfer_fee_fedwire), + value = "$11", + ), + ), + shouldShowTermsAndConditions = params.virtualAccountOnramp is VirtualAccountOnramp.Eligible, + isLoading = false, + onShowDetailsClick = ::onShowDetailsClick, + onDismiss = ::onDismiss, + onTermsClick = { urlOpener.openUrl(TERMS_OF_USE_URL) }, + onPrivacyClick = { urlOpener.openUrl(PRIVACY_POLICY_URL) }, + ), + ) fun onDismiss() { params.onDismiss() @@ -42,7 +62,27 @@ internal class TangemPayVirtualAccountDepositModel @Inject constructor( private fun onShowDetailsClick() { when (params.virtualAccountOnramp) { is VirtualAccountOnramp.Available -> params.onShowDetails(params.virtualAccountOnramp) - VirtualAccountOnramp.Eligible -> TODO() + VirtualAccountOnramp.Eligible -> createVirtualAccountOrder() + } + } + + private fun createVirtualAccountOrder() { + if (uiState.value.isLoading) return + uiState.update { it.copy(isLoading = true) } + modelScope.launch { + createVirtualAccountOrderUseCase( + userWalletId = params.userWalletId, + paymentAccountAddress = params.paymentAccountAddress, + ).fold( + ifLeft = { + uiState.update { state -> state.copy(isLoading = false) } + uiMessageSender.send(ToastMessage(resourceReference(R.string.common_unknown_error))) + }, + ifRight = { + uiState.update { state -> state.copy(isLoading = false) } + params.onOrderCreated() + }, + ) } } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayAddFundsUMConverter.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayAddFundsUMConverter.kt index 4a6b08f099..dae806fdf8 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayAddFundsUMConverter.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayAddFundsUMConverter.kt @@ -2,7 +2,6 @@ package com.tangem.features.tangempay.model.transformers import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.generated.icons.Icons import com.tangem.core.ui.res.generated.icons.ic_card_20 @@ -75,8 +74,8 @@ internal class TangemPayAddFundsUMConverter( imageVector = Icons.ic_sign_usd_20, tintReference = { TangemTheme.colors3.icon.brand }, ), - title = stringReference("Bank transfer"), - description = stringReference("Receive fiat USD via ACH/FedWire"), + title = resourceReference(R.string.tangempay_topup_bank_transfer_title), + description = resourceReference(R.string.tangempay_topup_bank_transfer_body), onClick = listener::onClickBankTransfer, ) }, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayAccountDetailsInnerRoute.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayAccountDetailsInnerRoute.kt index a88bd6099b..5978bb651f 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayAccountDetailsInnerRoute.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayAccountDetailsInnerRoute.kt @@ -23,4 +23,7 @@ internal sealed class TangemPayAccountDetailsInnerRoute : Route { @Serializable data object SelectPlan : TangemPayAccountDetailsInnerRoute() + + @Serializable + data object VirtualAccountDepositSuccess : TangemPayAccountDetailsInnerRoute() } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayCardDetailsInnerRoute.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayCardDetailsInnerRoute.kt index 6aaba8da25..933d8aa15a 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayCardDetailsInnerRoute.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayCardDetailsInnerRoute.kt @@ -27,4 +27,7 @@ internal sealed class TangemPayCardDetailsInnerRoute : Route { @Serializable data object LimitSetupSuccess : TangemPayCardDetailsInnerRoute() + + @Serializable + data object VirtualAccountDepositSuccess : TangemPayCardDetailsInnerRoute() } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayVirtualAccountDepositBottomSheet.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayVirtualAccountDepositBottomSheet.kt index 583004082d..4ef20b4232 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayVirtualAccountDepositBottomSheet.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayVirtualAccountDepositBottomSheet.kt @@ -33,12 +33,15 @@ import com.tangem.core.ui.ds2.button.TangemButton import com.tangem.core.ui.ds2.row.* import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.core.ui.res.generated.icons.Icons import com.tangem.core.ui.res.generated.icons.ic_info_24 import com.tangem.core.ui.res.generated.icons.ic_sign_usd_32 +import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.entity.TangemPayVirtualAccountDepositUM import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -76,11 +79,11 @@ private fun DepositContent(state: TangemPayVirtualAccountDepositUM, modifier: Mo ) { IntroIcons(modifier = Modifier.padding(top = TangemTheme.dimens2.x4)) TitleText( - text = stringReference("Bank transfer might take 1-2 business days"), + text = resourceReference(R.string.tangempay_bank_transfer_intro_title), modifier = Modifier.padding(top = TangemTheme.dimens2.x8), ) SubtitleText( - text = stringReference("Received USD will be converted to USDC by 1:1 rate"), + text = resourceReference(R.string.tangempay_bank_transfer_intro_subtitle), modifier = Modifier.padding(top = TangemTheme.dimens2.x2), ) FeesBlock( @@ -88,16 +91,18 @@ private fun DepositContent(state: TangemPayVirtualAccountDepositUM, modifier: Mo modifier = Modifier.padding(top = TangemTheme.dimens2.x6), ) InfoNotification( - text = stringReference("Deposit via ACH or FedWire only. SWIFT transfers will be returned."), + text = resourceReference(R.string.tangempay_bank_transfer_swift_warning), modifier = Modifier.padding(top = TangemTheme.dimens2.x4), ) TangemButton( modifier = Modifier .fillMaxWidth() .padding(top = TangemTheme.dimens2.x4), - text = stringReference("Show details"), + text = resourceReference(R.string.tangempay_bank_transfer_show_details), variant = TangemButton.Variant.Primary, size = TangemButton.Size.X12, + isLoading = state.isLoading, + isEnabled = !state.isLoading, onClick = state.onShowDetailsClick, ) if (state.shouldShowTermsAndConditions) { @@ -118,7 +123,7 @@ private fun FeesBlock(fees: ImmutableList Unit, onPrivacyClick: () -> Unit, modifier: Modifier = Modifier) { val linkStyle = SpanStyle(color = TangemTheme.colors3.text.primary) + val termsTitle = stringResourceSafe(R.string.common_terms_of_use) + val privacyTitle = stringResourceSafe(R.string.common_privacy_policy) + val fullText = stringResourceSafe(R.string.tangempay_bank_transfer_legal, termsTitle, privacyTitle) + + // Locate each link title in the resolved (localized) string and splice them in appearance order. + // Handles translations that reorder the %1$s/%2$s placeholders and skips a title that a translation + // does not contain verbatim — falling back to plain text instead of crashing on an invalid substring range. + val links = listOf( + Triple(fullText.indexOf(termsTitle), termsTitle, onTermsClick), + Triple(fullText.indexOf(privacyTitle), privacyTitle, onPrivacyClick), + ) + .filter { it.first >= 0 } + .sortedBy { it.first } + val text = buildAnnotatedString { - append("By using service, you agree with provider ") - withLink(LinkAnnotation.Clickable(tag = "terms", linkInteractionListener = { onTermsClick() })) { - withStyle(linkStyle) { append("Terms of Use") } - } - append(" and ") - withLink(LinkAnnotation.Clickable(tag = "privacy", linkInteractionListener = { onPrivacyClick() })) { - withStyle(linkStyle) { append("Privacy Policy") } + var cursor = 0 + links.forEach { (index, title, onClick) -> + if (index < cursor) return@forEach + append(fullText.substring(cursor, index)) + withLink(LinkAnnotation.Clickable(tag = title, linkInteractionListener = { onClick() })) { + withStyle(linkStyle) { append(title) } + } + cursor = index + title.length } + append(fullText.substring(cursor)) } Text( modifier = modifier.fillMaxWidth(), @@ -267,10 +288,17 @@ private fun SubtitleText(text: TextReference, modifier: Modifier = Modifier) { private fun previewState(shouldShowTermsAndConditions: Boolean) = TangemPayVirtualAccountDepositUM( fees = persistentListOf( - TangemPayVirtualAccountDepositUM.FeeRow(title = stringReference("ACH"), value = "$1"), - TangemPayVirtualAccountDepositUM.FeeRow(title = stringReference("FedWire"), value = "$11"), + TangemPayVirtualAccountDepositUM.FeeRow( + title = resourceReference(R.string.tangempay_bank_transfer_fee_ach), + value = "$1", + ), + TangemPayVirtualAccountDepositUM.FeeRow( + title = resourceReference(R.string.tangempay_bank_transfer_fee_fedwire), + value = "$11", + ), ), shouldShowTermsAndConditions = shouldShowTermsAndConditions, + isLoading = false, onShowDetailsClick = {}, onDismiss = {}, onTermsClick = {}, diff --git a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayVirtualAccountDepositModelTest.kt b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayVirtualAccountDepositModelTest.kt new file mode 100644 index 0000000000..522a9deb45 --- /dev/null +++ b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayVirtualAccountDepositModelTest.kt @@ -0,0 +1,155 @@ +package com.tangem.features.tangempay.model + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.core.decompose.model.MutableParamsContainer +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.navigation.url.UrlOpener +import com.tangem.core.ui.message.ToastMessage +import com.tangem.domain.models.account.BankCredentials +import com.tangem.domain.models.account.VirtualAccountOnramp +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.usecase.CreateVirtualAccountOrderUseCase +import com.tangem.domain.visa.error.VisaApiError +import com.tangem.features.tangempay.components.TangemPayVirtualAccountDepositComponent +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.Called +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +internal class TangemPayVirtualAccountDepositModelTest { + + private val userWalletId = UserWalletId("1234567890ABCDEF") + private val paymentAccountAddress = "0xcollateral" + + private val urlOpener: UrlOpener = mockk(relaxed = true) + private val uiMessageSender: UiMessageSender = mockk(relaxed = true) + private val createVirtualAccountOrderUseCase: CreateVirtualAccountOrderUseCase = mockk() + private val onShowDetails: (VirtualAccountOnramp.Available) -> Unit = mockk(relaxed = true) + private val onOrderCreated: () -> Unit = mockk(relaxed = true) + + @BeforeEach + fun resetMocks() { + clearMocks(createVirtualAccountOrderUseCase, onShowDetails, onOrderCreated, uiMessageSender) + } + + @Test + fun `GIVEN available WHEN show details THEN opens requisites and does not create order`() = runTest { + // Arrange + val onramp = VirtualAccountOnramp.Available(productInstanceId = "pi_1", bankCredentials = bankCredentials()) + val model = createModel(onramp) + + // Act + model.uiState.value.onShowDetailsClick() + advanceUntilIdle() + + // Assert + verify(exactly = 1) { onShowDetails(onramp) } + coVerify(exactly = 0) { createVirtualAccountOrderUseCase(any(), any()) } + } + + @Test + fun `GIVEN eligible and create succeeds WHEN show details THEN order created and loading reset`() = runTest { + // Arrange + coEvery { createVirtualAccountOrderUseCase(userWalletId, paymentAccountAddress) } returns Unit.right() + val model = createModel(VirtualAccountOnramp.Eligible) + + // Act + model.uiState.value.onShowDetailsClick() + advanceUntilIdle() + + // Assert + coVerify(exactly = 1) { createVirtualAccountOrderUseCase(userWalletId, paymentAccountAddress) } + verify(exactly = 1) { onOrderCreated() } + assertThat(model.uiState.value.isLoading).isFalse() + } + + @Test + fun `GIVEN eligible and create fails WHEN show details THEN toast shown and loading reset`() = runTest { + // Arrange + coEvery { + createVirtualAccountOrderUseCase(userWalletId, paymentAccountAddress) + } returns VisaApiError.Unspecified.left() + val model = createModel(VirtualAccountOnramp.Eligible) + + // Act + model.uiState.value.onShowDetailsClick() + advanceUntilIdle() + + // Assert + verify(exactly = 1) { uiMessageSender.send(any()) } + verify { onOrderCreated wasNot Called } + assertThat(model.uiState.value.isLoading).isFalse() + } + + @Test + fun `GIVEN already loading WHEN show details twice THEN use case invoked once`() = runTest { + // Arrange + val pending = CompletableDeferred>() + coEvery { createVirtualAccountOrderUseCase(userWalletId, paymentAccountAddress) } coAnswers { pending.await() } + val model = createModel(VirtualAccountOnramp.Eligible) + + // Act + model.uiState.value.onShowDetailsClick() // starts loading, use case suspends + advanceUntilIdle() + model.uiState.value.onShowDetailsClick() // gated by isLoading — must be ignored + advanceUntilIdle() + + // Assert + assertThat(model.uiState.value.isLoading).isTrue() + coVerify(exactly = 1) { createVirtualAccountOrderUseCase(userWalletId, paymentAccountAddress) } + + pending.complete(Unit.right()) // let the in-flight call finish cleanly + advanceUntilIdle() + } + + private fun TestScope.createModel(onramp: VirtualAccountOnramp) = TangemPayVirtualAccountDepositModel( + paramsContainer = MutableParamsContainer( + TangemPayVirtualAccountDepositComponent.Params( + virtualAccountOnramp = onramp, + userWalletId = userWalletId, + paymentAccountAddress = paymentAccountAddress, + onDismiss = {}, + onShowDetails = onShowDetails, + onOrderCreated = onOrderCreated, + ), + ), + dispatchers = createTestingCoroutineDispatcherProvider(), + urlOpener = urlOpener, + uiMessageSender = uiMessageSender, + createVirtualAccountOrderUseCase = createVirtualAccountOrderUseCase, + ) + + private fun bankCredentials() = BankCredentials( + type = "ACH", + beneficiaryName = "Test Beneficiary", + beneficiaryAddress = "Addr", + beneficiaryBankName = "Bank", + beneficiaryBankAddress = "Bank Addr", + accountNumber = "123", + routingNumber = "456", + ) + + private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider { + val testDispatcher = StandardTestDispatcher(testScheduler) + return TestingCoroutineDispatcherProvider( + main = testDispatcher, + mainImmediate = testDispatcher, + io = testDispatcher, + default = testDispatcher, + single = testDispatcher, + ) + } +} \ No newline at end of file diff --git a/features/virtual-accounts/details/api/src/main/kotlin/com/tangem/features/virtualaccount/details/component/VirtualAccountMainComponent.kt b/features/virtual-accounts/details/api/src/main/kotlin/com/tangem/features/virtualaccount/details/component/VirtualAccountMainComponent.kt index cd452567a0..9473fb0f4d 100644 --- a/features/virtual-accounts/details/api/src/main/kotlin/com/tangem/features/virtualaccount/details/component/VirtualAccountMainComponent.kt +++ b/features/virtual-accounts/details/api/src/main/kotlin/com/tangem/features/virtualaccount/details/component/VirtualAccountMainComponent.kt @@ -2,12 +2,14 @@ package com.tangem.features.virtualaccount.details.component import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.account.VirtualAccountOnramp import com.tangem.domain.models.wallet.UserWalletId interface VirtualAccountMainComponent : ComposableContentComponent { data class Params( val userWalletId: UserWalletId, + val virtualAccountOnramp: VirtualAccountOnramp.Available, ) interface Factory : ComponentFactory