From bd13529d6968186b5ef36caeaa13c7034d321c2b Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 14 Jul 2026 00:14:47 +0500 Subject: [PATCH] Updated on 2026-08-14 --- core/res/src/main/res/values/strings.xml | 82 ++------- .../DefaultPaymentAccountStatusFetcher.kt | 2 +- .../DefaultPaymentAccountStatusFetcherTest.kt | 4 +- .../models/account/VirtualAccountOnramp.kt | 8 + .../TangemPayCardPageScreenComponent.kt | 9 + .../components/TangemPayDetailsComponent.kt | 9 + ...TangemPayVaBankingDetailsErrorComponent.kt | 44 +++++ .../tangempay/di/TangemPayModelModule.kt | 5 + .../entity/TangemPayCardNavigation.kt | 5 + .../entity/TangemPayDetailsNavigation.kt | 5 + .../TangemPayVaBankingDetailsErrorUM.kt | 17 ++ .../tangempay/model/TangemPayCardPageModel.kt | 43 ++++- .../tangempay/model/TangemPayDetailsModel.kt | 25 ++- .../TangemPayVaBankingDetailsErrorModel.kt | 63 +++++++ .../TangemPayVirtualAccountDepositModel.kt | 3 + ...ngemPayVaBankingDetailsErrorBottomSheet.kt | 171 ++++++++++++++++++ ...TangemPayVaBankingDetailsErrorModelTest.kt | 132 ++++++++++++++ 17 files changed, 550 insertions(+), 77 deletions(-) create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayVaBankingDetailsErrorComponent.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayVaBankingDetailsErrorUM.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayVaBankingDetailsErrorModel.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayVaBankingDetailsErrorBottomSheet.kt create mode 100644 features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayVaBankingDetailsErrorModelTest.kt diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 2d1d56553e..933fd42368 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -93,7 +93,7 @@ Credit card or bank account Fund token Share your address or QR-code - Exchange one crypto for another + Between your portfolios You receive Add address Add address and select network @@ -105,7 +105,6 @@ %d addresses Choose address - Clear All Contact Contact name Copy address @@ -131,12 +130,10 @@ No contacts yet Contacts added will appear here Remove address - Save address Save contact Save to Wallet This contact will be linked to this wallet’s address book. No results found.\nTry another name - Select All Select network Address book Unsaved changes @@ -639,8 +636,6 @@ Long transaction time The transaction amount was refunded in %1$s to your wallet due to OKX or bridge rules. %2$s The amount was refunded in %1$s (%2$s network) - Your funds have been refunded in %1$s to your wallet on the %2$s network, in accordance with OKX exchange rules. - Refunded in %s Visit provider’s website for verification KYC verification required by provider Purchase completed @@ -726,11 +721,6 @@ Can\'t send a transaction Coin description error Review portfolio and explore earn opportunities - Earn opportunities - All your assets are at work, explore new opportunities - Get up to %1$s annually - Max potential rewards %1$s - %1$s/year Portfolio review For You Update now @@ -933,14 +923,12 @@ In your portfolio Your portfolio **Token not supported**. This token is currently not supported in the wallet - Other eligible tokens Market Pulse Quick actions Clear all Search tokens - Recent searches + Recent\'s In your portfolio - Recent tokens Result See tokens under 100k USD market cap Show tokens @@ -1311,21 +1299,6 @@ By balance Organize tokens Ungroup - Eligible cashback will be distributed to: - You\'re already enrolled in %1$s - Eligible tokens - Enroll - You\'re successfully enrolled in %1$s - This campaign no longer exists or has expired - Campaign not active - Earn 0.5% cashback on every swap over $500, on any pair except stable to stable. Max payout $50 per swap.\n\nComplete five qualifying swaps and unlock an extra $10 bonus.\n\nRewards are paid weekly in USDT or USDC on the address selected. - Select cashback account - Select token - Enroll in %1$s - I agree with %1$s - I agree with - %1$s Terms - Earn cashback on every swap from $10K until the end of July.\n\nRates step up with size: 0.10% from $10K, 0.20% from $20K, 0.50% from $100K.\n\nMax payout: $500 per swap, and $10,000 per wallet per swap direction until campaign lasts. Stable coin into stablecoin swaps are excluded.\n\nPayout arrives weekly in USDT or USDC address of your choice. %s support Push Notifications are enabled but won\'t work until you allow them Allow notifications @@ -1358,12 +1331,12 @@ No supported tokens found This QR code contains parameters that are not recognized: %s. Some payment details may be lost if you continue. Unknown Parameters - Get crypto by card, bank transfer & more + Credit card or bank account Share your address or QR-code - Convert crypto to fiat currency - Exchange and send in one step - Transfer crypto to another wallet - Exchange one crypto for another + Sell crypto securely + Send with swap to another token + Send to another wallet + Between your portfolios Other Quick top up No memo required @@ -1549,7 +1522,7 @@ Total amount exceeds balance Are you sure you want to change the receiving token? This will reset your previously entered data. Changing token - Swap & Send + Swap and send Proceed with swap? This will clear your previous data. Confirm Conversion Sending any other currency will result in its irreversible loss. @@ -1657,9 +1630,9 @@ To begin staking, you need to activate your TON account first. Account activation To start staking in TON, first send a small transaction to your own address — this will activate your wallet. - Up to 0.2 GRAM may be required in addition to the network fee to complete the transaction. Any unused amount will be refunded. - 0.2 GRAM is required to proceed with this operation, in addition to the network fee. Please top up your balance. - GRAM reserve required + Up to 0.2 TON may be required in addition to the network fee to complete the transaction. Any unused amount will be refunded. + 0.2 TON is required to proceed with this operation, in addition to the network fee. Please top up your balance. + TON reserve required This action will close other positions or switch them to withdrawal status, according to network rules. Positions status Unlock your money to withdraw it from staking process. Unlocking takes %s. @@ -1864,9 +1837,6 @@ Add funds Top-up options Add to Google Wallet - Cancel %1$s, move to %2$s - To pay monthly fee for plan and start use card - Top-up your account on %1$s Card Number PIN code The card is fully ready for payments. @@ -1901,8 +1871,6 @@ Card name Reveal Details - If it will remain below zero your %1$s cards will be closed on %2$s - Top up your account shortly Details Please try again later Unfreeze Card @@ -1930,22 +1898,12 @@ %d card %d cards - It was made due to your suspicious behavior. Contact support to learn more - Cashback deactivated - Will be deposited on %1$s - %1$s cashback in %2$s Change PIN-code Come back to the app if you forget it. Card - Error loading - Your %1$s plan is active till %2$s, then we will move you to %3$s. %4$s won\'t be charged. Change plan - %1$s monthly fee will be charged on %2$s Card related Plan related - Stay on %1$s - Your transition on %1$s will be canceled - Do you want to stay on %1$s? Current plan Set a limit from %s to %s Set limits @@ -2002,7 +1960,6 @@ Hide KYC block Sorry, we couldn\'t verify your profile. - Select plan You can have up to 3 cards. Delete one to add a new card. Maximum Cards Issued Yes – to operate a regulated Visa card, identity verification is mandatory. KYC is handled by Sumsub (compliance partner). @@ -2057,16 +2014,7 @@ Select Upgrade plan Compare plans - Your %1$s plan and %2$s cards will be active till %3$s - You can cancel this transition till %1$s - Your %1$s cards will be closed - %1$s monthly fee will be taken from your account - On %1$s we will move you to %2$s plan - No fee applied - You will get your virtual %1$s in minutes - You are switching to %1$s Confirm selection - We will issue %1$s for you Select plan We’re fixing a technical issue. Please try again later. Service temporarily unavailable @@ -2075,7 +2023,6 @@ Set up new PIN Set PIN Account closed - Inactive Replacing your card Use your card or ring to renew session Use your card or ring to renew session @@ -2093,7 +2040,6 @@ USDC on Polygon network Please try again or contact support if the issue persists Couldn\'t load banking details - Visa Benefits Funds from refunded purchases won’t be returned your on-chain Polygon balance or be available for withdrawal, but will stay on your card balance for purchases Please note Your PIN code @@ -2137,10 +2083,6 @@ Show QR code Can’t load data of the token Go to swap - Last update: %1$s - Negative outlook - Neutral outlook - Positive outlook Token summary Exchange this token for another at %1$s service fees from February %2$s-%3$s. Swap with Changelly, %s fees @@ -2658,7 +2600,7 @@ The fee will be deducted, and your assets will be resupplied. To continue generating yield, approval is required. Confirm approval - Current APY %1$s%% + Average APY %1$s%% Your funds are currently supplied to the Aave protocol, but you can manage them at any time. Your %s is supplied to Aave Unable to load chart... 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 e77fc8bec5..6c262fdb70 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 @@ -408,7 +408,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( return onboardingRepository.getBankCredentials(userWalletId, accountInstance.id).fold( ifLeft = { error -> logger.e("getBankCredentials failed for ${accountInstance.id}: $error") - null + VirtualAccountOnramp.BankCredentialsError }, ifRight = { credentials -> VirtualAccountOnramp.Available( 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 a5e65e1f9c..4c7bafec4f 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 @@ -238,7 +238,7 @@ internal class DefaultPaymentAccountStatusFetcherTest { } @Test - fun `GIVEN toggle on and ACCOUNT instance but bank credentials fetch fails WHEN invoke THEN virtualAccount is null`() = + fun `GIVEN toggle on and ACCOUNT instance but bank credentials fetch fails WHEN invoke THEN virtualAccount is Error`() = runTest { // Arrange val customerInfo = buildCustomerInfo( @@ -256,7 +256,7 @@ internal class DefaultPaymentAccountStatusFetcherTest { // Assert val loaded = storedStatuses.lastLoaded() - assertThat(loaded.virtualAccount).isNull() + assertThat(loaded.virtualAccount).isEqualTo(VirtualAccountOnramp.BankCredentialsError) } @Test diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/account/VirtualAccountOnramp.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/account/VirtualAccountOnramp.kt index 46b95dd157..e3ba1c80bd 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/account/VirtualAccountOnramp.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/account/VirtualAccountOnramp.kt @@ -21,4 +21,12 @@ sealed interface VirtualAccountOnramp { val productInstanceId: String, val bankCredentials: BankCredentials, ) : VirtualAccountOnramp + + /** + * VA product instance exists, but its bank credentials failed to load. The bank-transfer entry point + * stays visible; tapping it surfaces a retryable "couldn't load banking details" error instead of the + * requisites. Transient — never persisted, re-resolved on the next status fetch. + */ + @Serializable + data object BankCredentialsError : VirtualAccountOnramp } \ No newline at end of file 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 a6ec072508..a014677344 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 @@ -130,6 +130,15 @@ internal class TangemPayCardPageScreenComponent( onFieldCopied = model::onVaFieldCopied, ), ) + is TangemPayCardNavigation.VaBankingDetailsError -> TangemPayVaBankingDetailsErrorComponent( + appComponentContext = context, + params = TangemPayVaBankingDetailsErrorComponent.Params( + userWalletId = navigation.userWalletId, + onDismiss = model.bottomSheetNavigation::dismiss, + onContactSupport = model::onContactSupportClicked, + onResolved = model::onVaBankingDetailsResolved, + ), + ) is TangemPayCardNavigation.Receive -> tokenReceiveComponentFactory.create( context = context, params = TokenReceiveComponent.Params( 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 edfe81adf4..c7520ca8aa 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 @@ -159,6 +159,15 @@ internal class TangemPayDetailsComponent( onFieldCopied = model::onVaFieldCopied, ), ) + is TangemPayDetailsNavigation.VaBankingDetailsError -> TangemPayVaBankingDetailsErrorComponent( + appComponentContext = context, + params = TangemPayVaBankingDetailsErrorComponent.Params( + userWalletId = navigation.userWalletId, + onDismiss = model.bottomSheetNavigation::dismiss, + onContactSupport = model::onContactSupportClicked, + onResolved = model::onVaBankingDetailsResolved, + ), + ) is TangemPayDetailsNavigation.IssueAdditionalCard -> TangemPayIssueAdditionalCardComponent( appComponentContext = context, params = TangemPayIssueAdditionalCardComponent.Params( diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayVaBankingDetailsErrorComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayVaBankingDetailsErrorComponent.kt new file mode 100644 index 0000000000..08901ed2c9 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayVaBankingDetailsErrorComponent.kt @@ -0,0 +1,44 @@ +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.TangemPayVaBankingDetailsErrorModel +import com.tangem.features.tangempay.ui.TangemPayVaBankingDetailsErrorBottomSheet + +/** + * Error bottom sheet shown when VA bank credentials fail to load ([VirtualAccountOnramp.BankCredentialsError]). + * + * "Try again" re-fetches the payment account status while showing a loader on the button; on success the + * resolved on-ramp is handed back via [Params.onResolved] (the parent opens the bank-transfer sheet), otherwise + * the error stays visible with the loader cleared. + */ +internal class TangemPayVaBankingDetailsErrorComponent( + appComponentContext: AppComponentContext, + params: Params, +) : ComposableBottomSheetComponent, AppComponentContext by appComponentContext { + + private val model: TangemPayVaBankingDetailsErrorModel = getOrCreateModel(params = params) + + override fun dismiss() { + model.onDismiss() + } + + @Composable + override fun BottomSheet() { + val state by model.uiState.collectAsStateWithLifecycle() + TangemPayVaBankingDetailsErrorBottomSheet(state = state) + } + + data class Params( + val userWalletId: UserWalletId, + val onDismiss: () -> Unit, + val onContactSupport: () -> Unit, + val onResolved: (VirtualAccountOnramp) -> Unit, + ) +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt index 941998d5d0..03b6a31a11 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt @@ -50,6 +50,11 @@ internal interface TangemPayModelModule { @ClassKey(TangemPayVirtualAccountDepositModel::class) fun bindTangemPayVirtualAccountDepositModel(model: TangemPayVirtualAccountDepositModel): Model + @Binds + @IntoMap + @ClassKey(TangemPayVaBankingDetailsErrorModel::class) + fun bindTangemPayVaBankingDetailsErrorModel(model: TangemPayVaBankingDetailsErrorModel): Model + @Binds @IntoMap @ClassKey(TangemPayViewPinModel::class) 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 74c99dd180..081f7a9bf0 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 @@ -48,6 +48,11 @@ internal sealed class TangemPayCardNavigation { val bankCredentials: BankCredentials, ) : TangemPayCardNavigation() + @Serializable + data class VaBankingDetailsError( + val userWalletId: UserWalletId, + ) : TangemPayCardNavigation() + @Serializable data class Receive(val config: TokenReceiveConfig) : TangemPayCardNavigation() } \ No newline at end of file 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 82b020d3f7..ac57f7b8ec 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 @@ -39,6 +39,11 @@ internal sealed class TangemPayDetailsNavigation { val bankCredentials: BankCredentials, ) : TangemPayDetailsNavigation() + @Serializable + data class VaBankingDetailsError( + val userWalletId: UserWalletId, + ) : TangemPayDetailsNavigation() + @Serializable data class TransactionDetails( val transaction: TangemPayTxHistoryItem, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayVaBankingDetailsErrorUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayVaBankingDetailsErrorUM.kt new file mode 100644 index 0000000000..7b408ada1b --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayVaBankingDetailsErrorUM.kt @@ -0,0 +1,17 @@ +package com.tangem.features.tangempay.entity + +import androidx.compose.runtime.Immutable + +/** + * UI state for the "couldn't load banking details" bottom sheet (VA MVP0, TWI-1638). + * + * @property isRetryLoading whether the "Try again" button shows a loader while the payment account status + * is being re-fetched. While `true` both actions are disabled. + */ +@Immutable +internal data class TangemPayVaBankingDetailsErrorUM( + val isRetryLoading: Boolean, + val onRetryClick: () -> Unit, + val onContactSupportClick: () -> Unit, + val onDismiss: () -> Unit, +) \ No newline at end of file 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 2468874e6a..fea873c6c6 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 @@ -7,6 +7,7 @@ import com.arkivanov.decompose.router.slot.dismiss import com.tangem.common.routing.AppRoute import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.analytics.models.Basic import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -24,6 +25,9 @@ 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_arrow_refresh_20 import com.tangem.core.ui.test.TangemPayTestTags +import com.tangem.domain.feedback.SendFeedbackEmailUseCase +import com.tangem.domain.feedback.models.FeedbackEmailType +import com.tangem.domain.feedback.models.WalletMetaInfo import com.tangem.domain.models.StatusSource import com.tangem.domain.models.TokenReceiveConfig import com.tangem.domain.models.account.AccountStatus @@ -72,11 +76,12 @@ import com.tangem.core.ui.R as CoreUiR @ModelScoped internal class TangemPayCardPageModel @Inject constructor( paramsContainer: ParamsContainer, - paymentAccountStatusSupplier: PaymentAccountStatusSupplier, + private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier, private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher, override val dispatchers: CoroutineDispatcherProvider, private val router: Router, private val analytics: AnalyticsEventHandler, + private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val cardDetailsRepository: TangemPayCardDetailsRepository, private val uiMessageSender: UiMessageSender, private val changeCardFrozenStateUseCase: ChangeCardFrozenStateUseCase, @@ -488,7 +493,16 @@ internal class TangemPayCardPageModel @Inject constructor( override fun onClickBankTransfer() { val loaded = currentStatus.value.ifLoadedOrNull { it } ?: return - val onramp = loaded.virtualAccount ?: return + when (val onramp = loaded.virtualAccount) { + null -> return + is VirtualAccountOnramp.BankCredentialsError -> showVaBankingDetailsError() + is VirtualAccountOnramp.Available, + VirtualAccountOnramp.Eligible, + -> openVirtualAccountDeposit(onramp, loaded) + } + } + + private fun openVirtualAccountDeposit(onramp: VirtualAccountOnramp, loaded: PaymentAccountStatusValue.Loaded) { analytics.send(TangemPayAnalyticsEvents.VaTopupButtonClicked()) bottomSheetNavigation.dismiss() bottomSheetNavigation.activate( @@ -500,6 +514,31 @@ internal class TangemPayCardPageModel @Inject constructor( ) } + private fun showVaBankingDetailsError() { + bottomSheetNavigation.dismiss() + bottomSheetNavigation.activate( + TangemPayCardNavigation.VaBankingDetailsError(userWalletId = userWalletId), + ) + } + + fun onVaBankingDetailsResolved(onramp: VirtualAccountOnramp) { + val loaded = currentStatus.value.ifLoadedOrNull { it } ?: return + openVirtualAccountDeposit(onramp, loaded) + } + + fun onContactSupportClicked() { + analytics.send(Basic.ButtonSupport(source = AnalyticsParam.ScreensSources.TangemPay)) + val customerId = currentStatus.value.ifLoadedOrNull { it.customerId } ?: return + modelScope.launch { + sendFeedbackEmailUseCase.invoke( + type = FeedbackEmailType.Visa.FeatureIsBeta( + walletMetaInfo = WalletMetaInfo(userWalletId = userWalletId), + customerId = customerId, + ), + ) + } + } + fun onVirtualAccountOrderCreated() { analytics.send(TangemPayAnalyticsEvents.VaSuccessScreenActivation()) bottomSheetNavigation.dismiss() 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 1da0574cd4..09649cb4ee 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 @@ -67,7 +67,7 @@ import javax.inject.Inject @ModelScoped internal class TangemPayDetailsModel @Inject constructor( paramsContainer: ParamsContainer, - paymentAccountStatusSupplier: PaymentAccountStatusSupplier, + private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier, override val dispatchers: CoroutineDispatcherProvider, private val analytics: AnalyticsEventHandler, private val router: Router, @@ -313,7 +313,16 @@ internal class TangemPayDetailsModel @Inject constructor( override fun onClickBankTransfer() { val loaded = currentStatus.value.ifLoadedOrNull { it } ?: return - val onramp = loaded.virtualAccount ?: return + when (val onramp = loaded.virtualAccount) { + null -> return + is VirtualAccountOnramp.BankCredentialsError -> showVaBankingDetailsError() + is VirtualAccountOnramp.Available, + VirtualAccountOnramp.Eligible, + -> openVirtualAccountDeposit(onramp, loaded) + } + } + + private fun openVirtualAccountDeposit(onramp: VirtualAccountOnramp, loaded: PaymentAccountStatusValue.Loaded) { analytics.send(TangemPayAnalyticsEvents.VaTopupButtonClicked()) bottomSheetNavigation.dismiss() bottomSheetNavigation.activate( @@ -325,6 +334,18 @@ internal class TangemPayDetailsModel @Inject constructor( ) } + private fun showVaBankingDetailsError() { + bottomSheetNavigation.dismiss() + bottomSheetNavigation.activate( + TangemPayDetailsNavigation.VaBankingDetailsError(userWalletId = userWalletId), + ) + } + + fun onVaBankingDetailsResolved(onramp: VirtualAccountOnramp) { + val loaded = currentStatus.value.ifLoadedOrNull { it } ?: return + openVirtualAccountDeposit(onramp, loaded) + } + fun onVirtualAccountOrderCreated() { analytics.send(TangemPayAnalyticsEvents.VaSuccessScreenActivation()) bottomSheetNavigation.dismiss() diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayVaBankingDetailsErrorModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayVaBankingDetailsErrorModel.kt new file mode 100644 index 0000000000..b9c1439a4d --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayVaBankingDetailsErrorModel.kt @@ -0,0 +1,63 @@ +package com.tangem.features.tangempay.model + +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.domain.models.account.VirtualAccountOnramp +import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher +import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier +import com.tangem.features.tangempay.components.TangemPayVaBankingDetailsErrorComponent +import com.tangem.features.tangempay.entity.TangemPayVaBankingDetailsErrorUM +import com.tangem.features.tangempay.utils.ifLoadedOrNull +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Stable +@ModelScoped +internal class TangemPayVaBankingDetailsErrorModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher, + private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier, +) : Model() { + + private val params = paramsContainer.require() + + val uiState: StateFlow + field = MutableStateFlow( + TangemPayVaBankingDetailsErrorUM( + isRetryLoading = false, + onRetryClick = ::onRetryClick, + onContactSupportClick = params.onContactSupport, + onDismiss = ::onDismiss, + ), + ) + + fun onDismiss() { + params.onDismiss() + } + + private fun onRetryClick() { + if (uiState.value.isRetryLoading) return + uiState.update { it.copy(isRetryLoading = true) } + modelScope.launch { + paymentAccountStatusFetcher.invoke(params.userWalletId) + val onramp = paymentAccountStatusSupplier.invoke(params.userWalletId) + .first() + .ifLoadedOrNull { it.virtualAccount } + when (onramp) { + is VirtualAccountOnramp.Available, + VirtualAccountOnramp.Eligible, + -> params.onResolved(onramp) + // Still failing (BankCredentialsError) or unavailable — keep the sheet, clear the loader. + else -> uiState.update { it.copy(isRetryLoading = false) } + } + } + } +} \ No newline at end of file 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 8850ca7669..6bc0a4a958 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 @@ -81,6 +81,9 @@ internal class TangemPayVirtualAccountDepositModel @Inject constructor( analytics.send(TangemPayAnalyticsEvents.VaShowDetailsFirstTimeClicked()) createVirtualAccountOrder() } + // Error onramp is intercepted before this sheet opens (a dedicated error sheet is shown instead); + // the branch only keeps the `when` exhaustive. + VirtualAccountOnramp.BankCredentialsError -> onDismiss() } } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayVaBankingDetailsErrorBottomSheet.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayVaBankingDetailsErrorBottomSheet.kt new file mode 100644 index 0000000000..50b720bdce --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayVaBankingDetailsErrorBottomSheet.kt @@ -0,0 +1,171 @@ +package com.tangem.features.tangempay.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetType +import com.tangem.core.ui.ds.topbar.TangemTopBar +import com.tangem.core.ui.ds.topbar.TangemTopBarType +import com.tangem.core.ui.ds2.button.Close +import com.tangem.core.ui.ds2.button.TangemButton +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.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_error_28 +import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.entity.TangemPayVaBankingDetailsErrorUM + +@Composable +internal fun TangemPayVaBankingDetailsErrorBottomSheet(state: TangemPayVaBankingDetailsErrorUM) { + TangemBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = state.onDismiss, + content = TangemBottomSheetConfigContent.Empty, + ), + type = TangemBottomSheetType.Modal, + containerColor = TangemTheme.colors3.bg.secondary, + title = { + TangemTopBar( + type = TangemTopBarType.BottomSheet, + title = null, + endContent = { TangemButton.Close(onClick = state.onDismiss) }, + ) + }, + content = { _ -> Content(state) }, + ) +} + +@Composable +private fun Content(state: TangemPayVaBankingDetailsErrorUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = TangemTheme.dimens2.x4) + .padding(bottom = TangemTheme.dimens2.x4), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + WarningIcon(modifier = Modifier.padding(top = TangemTheme.dimens2.x4)) + TitleText( + text = resourceReference(R.string.tangempay_va_banking_details_error_title), + modifier = Modifier.padding(top = TangemTheme.dimens2.x8), + ) + SubtitleText( + text = resourceReference(R.string.tangempay_va_banking_details_error_description), + modifier = Modifier.padding(top = TangemTheme.dimens2.x2), + ) + TangemButton( + modifier = Modifier + .fillMaxWidth() + .padding(top = TangemTheme.dimens2.x8), + text = resourceReference(R.string.common_contact_support), + variant = TangemButton.Variant.Secondary, + size = TangemButton.Size.X12, + isEnabled = !state.isRetryLoading, + onClick = state.onContactSupportClick, + ) + TangemButton( + modifier = Modifier + .fillMaxWidth() + .padding(top = TangemTheme.dimens2.x2), + text = resourceReference(R.string.alert_button_try_again), + variant = TangemButton.Variant.Primary, + size = TangemButton.Size.X12, + isLoading = state.isRetryLoading, + isEnabled = !state.isRetryLoading, + onClick = state.onRetryClick, + ) + } +} + +@Composable +private fun WarningIcon(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .size(TangemTheme.dimens2.x20) + .clip(CircleShape) + .background(TangemTheme.colors3.bg.status.warningSubtle), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.size(TangemTheme.dimens2.x7), + imageVector = Icons.ic_error_28, + contentDescription = null, + tint = TangemTheme.colors3.icon.status.warning, + ) + } +} + +@Composable +private fun TitleText(text: TextReference, modifier: Modifier = Modifier) { + Text( + modifier = modifier.fillMaxWidth(), + text = text.resolveReference(), + style = TangemTheme.typography3.heading.small, + color = TangemTheme.colors3.text.primary, + textAlign = TextAlign.Center, + ) +} + +@Composable +private fun SubtitleText(text: TextReference, modifier: Modifier = Modifier) { + Text( + modifier = modifier.fillMaxWidth(), + text = text.resolveReference(), + style = TangemTheme.typography3.subheading.medium, + color = TangemTheme.colors3.text.secondary, + textAlign = TextAlign.Center, + ) +} + +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun TangemPayVaBankingDetailsErrorPreview( + @PreviewParameter(VaBankingDetailsErrorPreviewProvider::class) state: TangemPayVaBankingDetailsErrorUM, +) { + TangemThemePreviewRedesign { + Content( + state = state, + modifier = Modifier.background(TangemTheme.colors3.bg.secondary), + ) + } +} + +private class VaBankingDetailsErrorPreviewProvider : + CollectionPreviewParameterProvider( + collection = listOf( + TangemPayVaBankingDetailsErrorUM( + isRetryLoading = false, + onRetryClick = {}, + onContactSupportClick = {}, + onDismiss = {}, + ), + TangemPayVaBankingDetailsErrorUM( + isRetryLoading = true, + onRetryClick = {}, + onContactSupportClick = {}, + onDismiss = {}, + ), + ), + ) \ No newline at end of file diff --git a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayVaBankingDetailsErrorModelTest.kt b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayVaBankingDetailsErrorModelTest.kt new file mode 100644 index 0000000000..7f16665de1 --- /dev/null +++ b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayVaBankingDetailsErrorModelTest.kt @@ -0,0 +1,132 @@ +package com.tangem.features.tangempay.model + +import arrow.core.Either +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.core.decompose.model.MutableParamsContainer +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.account.PaymentAccountStatusValue +import com.tangem.domain.models.account.VirtualAccountOnramp +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher +import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier +import com.tangem.features.tangempay.components.TangemPayVaBankingDetailsErrorComponent +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.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.flowOf +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 TangemPayVaBankingDetailsErrorModelTest { + + private val userWalletId = UserWalletId("1234567890ABCDEF") + + private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher = mockk() + private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier = mockk() + private val onDismiss: () -> Unit = mockk(relaxed = true) + private val onContactSupport: () -> Unit = mockk(relaxed = true) + private val onResolved: (VirtualAccountOnramp) -> Unit = mockk(relaxed = true) + + @BeforeEach + fun resetMocks() { + clearMocks(paymentAccountStatusFetcher, paymentAccountStatusSupplier, onDismiss, onContactSupport, onResolved) + } + + @Test + fun `GIVEN refetch resolves to available WHEN retry THEN onResolved called`() = runTest { + // Arrange + val onramp = VirtualAccountOnramp.Available(productInstanceId = "pi_1", bankCredentials = mockk()) + coEvery { paymentAccountStatusFetcher.invoke(userWalletId) } returns Unit.right() + stubSupplier(onramp) + val model = createModel() + + // Act + model.uiState.value.onRetryClick() + advanceUntilIdle() + + // Assert + verify(exactly = 1) { onResolved(onramp) } + } + + @Test + fun `GIVEN refetch still fails WHEN retry THEN onResolved not called and loading reset`() = runTest { + // Arrange + coEvery { paymentAccountStatusFetcher.invoke(userWalletId) } returns Unit.right() + stubSupplier(VirtualAccountOnramp.BankCredentialsError) + val model = createModel() + + // Act + model.uiState.value.onRetryClick() + advanceUntilIdle() + + // Assert + verify { onResolved wasNot Called } + assertThat(model.uiState.value.isRetryLoading).isFalse() + } + + @Test + fun `GIVEN refetch in progress WHEN retry twice THEN fetch invoked once and loading shown`() = runTest { + // Arrange + val pending = CompletableDeferred>() + coEvery { paymentAccountStatusFetcher.invoke(userWalletId) } coAnswers { pending.await() } + stubSupplier(VirtualAccountOnramp.BankCredentialsError) + val model = createModel() + + // Act + model.uiState.value.onRetryClick() // starts loading, fetch suspends + advanceUntilIdle() + model.uiState.value.onRetryClick() // gated by isRetryLoading — must be ignored + advanceUntilIdle() + + // Assert + assertThat(model.uiState.value.isRetryLoading).isTrue() + coVerify(exactly = 1) { paymentAccountStatusFetcher.invoke(userWalletId) } + + pending.complete(Unit.right()) // let the in-flight call finish cleanly + advanceUntilIdle() + } + + private fun stubSupplier(onramp: VirtualAccountOnramp) { + val loaded = mockk() + every { loaded.virtualAccount } returns onramp + val status = mockk() + every { status.value } returns loaded + every { paymentAccountStatusSupplier.invoke(userWalletId) } returns flowOf(status) + } + + private fun TestScope.createModel() = TangemPayVaBankingDetailsErrorModel( + paramsContainer = MutableParamsContainer( + TangemPayVaBankingDetailsErrorComponent.Params( + userWalletId = userWalletId, + onDismiss = onDismiss, + onContactSupport = onContactSupport, + onResolved = onResolved, + ), + ), + dispatchers = createTestingCoroutineDispatcherProvider(), + paymentAccountStatusFetcher = paymentAccountStatusFetcher, + paymentAccountStatusSupplier = paymentAccountStatusSupplier, + ) + + 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