Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-14 00:14:47 +05:00
parent 5f1a711c8c
commit bd13529d69
17 changed files with 550 additions and 77 deletions

View file

@ -93,7 +93,7 @@
<string name="addfunds_buy_row_description">Credit card or bank account</string>
<string name="addfunds_fund_token">Fund token</string>
<string name="addfunds_receive_row_description">Share your address or QR-code</string>
<string name="addfunds_swap_row_description">Exchange one crypto for another</string>
<string name="addfunds_swap_row_description">Between your portfolios</string>
<string name="addfunds_you_receive_title">You receive</string>
<string name="address_book_add_address">Add address</string>
<string name="address_book_add_address_description">Add address and select network</string>
@ -105,7 +105,6 @@
<item quantity="other">%d addresses</item>
</plurals>
<string name="address_book_choose_address">Choose address</string>
<string name="address_book_clear_all_networks">Clear All</string>
<string name="address_book_contact">Contact</string>
<string name="address_book_contact_name">Contact name</string>
<string name="address_book_copy_address">Copy address</string>
@ -131,12 +130,10 @@
<string name="address_book_no_contacts">No contacts yet</string>
<string name="address_book_no_contacts_description">Contacts added will appear here</string>
<string name="address_book_remove_address">Remove address</string>
<string name="address_book_save_address">Save address</string>
<string name="address_book_save_contact">Save contact</string>
<string name="address_book_save_to_wallet_title">Save to Wallet</string>
<string name="address_book_save_wallet_to_description">This contact will be linked to this wallets address book.</string>
<string name="address_book_search_no_results">No results found.\nTry another name</string>
<string name="address_book_select_all_networks">Select All</string>
<string name="address_book_select_network">Select network</string>
<string name="address_book_title">Address book</string>
<string name="address_book_unsaved_changes">Unsaved changes</string>
@ -639,8 +636,6 @@
<string name="express_exchange_notification_long_transaction_time_title">Long transaction time</string>
<string name="express_exchange_notification_refund_text">The transaction amount was refunded in %1$s to your wallet due to OKX or bridge rules. %2$s</string>
<string name="express_exchange_notification_refund_title">The amount was refunded in %1$s (%2$s network)</string>
<string name="express_exchange_notification_refunded_in_text">Your funds have been refunded in %1$s to your wallet on the %2$s network, in accordance with OKX exchange rules.</string>
<string name="express_exchange_notification_refunded_in_title">Refunded in %s</string>
<string name="express_exchange_notification_verification_text">Visit providers website for verification</string>
<string name="express_exchange_notification_verification_title">KYC verification required by provider</string>
<string name="express_exchange_status_bought">Purchase completed</string>
@ -726,11 +721,6 @@
<string name="feedback_subject_tx_failed">Can\'t send a transaction</string>
<string name="feedback_token_description_error">Coin description error</string>
<string name="for_you_description">Review portfolio and explore earn opportunities</string>
<string name="for_you_earn_opportunities">Earn opportunities</string>
<string name="for_you_earn_opportunities_all_tokens_active">All your assets are at work, explore new opportunities</string>
<string name="for_you_earn_opportunities_no_available_tokens">Get up to %1$s annually</string>
<string name="for_you_earn_opportunities_tokens_rewards">Max potential rewards %1$s</string>
<string name="for_you_earn_per_year">%1$s/year</string>
<string name="for_you_portfolio_review_title">Portfolio review</string>
<string name="for_you_title">For You</string>
<string name="force_update_action">Update now</string>
@ -933,14 +923,12 @@
<string name="markets_portfolio_block_subtitle">In your portfolio</string>
<string name="markets_portfolio_block_title">Your portfolio</string>
<string name="markets_portfolio_block_token_unsupported">**Token not supported**. This token is currently not supported in the wallet</string>
<string name="markets_portfolio_eligible_block_title">Other eligible tokens</string>
<string name="markets_pulse_common_title">Market Pulse</string>
<string name="markets_quick_actions">Quick actions</string>
<string name="markets_search_clear_all_hints">Clear all</string>
<string name="markets_search_header_title">Search tokens</string>
<string name="markets_search_hint_header">Recent searches</string>
<string name="markets_search_hint_header">Recent\'s</string>
<string name="markets_search_portfolio_header">In your portfolio</string>
<string name="markets_search_recent_tokens_header">Recent tokens</string>
<string name="markets_search_result_title">Result</string>
<string name="markets_search_see_tokens_under_100k">See tokens under 100k USD market cap</string>
<string name="markets_search_show_tokens">Show tokens</string>
@ -1311,21 +1299,6 @@
<string name="organize_tokens_sort_by_balance">By balance</string>
<string name="organize_tokens_title">Organize tokens</string>
<string name="organize_tokens_ungroup">Ungroup</string>
<string name="promo_campaign_already_activated_subtitle">Eligible cashback will be distributed to:</string>
<string name="promo_campaign_already_activated_title">You\'re already enrolled in %1$s</string>
<string name="promo_campaign_eligible_tokens">Eligible tokens</string>
<string name="promo_campaign_enroll">Enroll</string>
<string name="promo_campaign_enroll_success_title">You\'re successfully enrolled in %1$s</string>
<string name="promo_campaign_not_active_subtitle">This campaign no longer exists or has expired</string>
<string name="promo_campaign_not_active_title">Campaign not active</string>
<string name="promo_campaign_reactivation_summary_description">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.</string>
<string name="promo_campaign_select_cashback_account">Select cashback account</string>
<string name="promo_campaign_select_token">Select token</string>
<string name="promo_campaign_summary_title">Enroll in %1$s</string>
<string name="promo_campaign_terms_agreement">I agree with %1$s</string>
<string name="promo_campaign_terms_agreement_android">I agree with</string>
<string name="promo_campaign_terms_link">%1$s Terms</string>
<string name="promo_campaign_whale_swap_summary_description">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.</string>
<string name="provider_name_support">%s support</string>
<string name="push_notification_settings_banner_description">Push Notifications are enabled but won\'t work until you allow them</string>
<string name="push_notification_settings_banner_title">Allow notifications</string>
@ -1358,12 +1331,12 @@
<string name="qr_scanner_error_unsupported_network_title">No supported tokens found</string>
<string name="qr_scanner_warning_unknown_parameters_message">This QR code contains parameters that are not recognized: %s. Some payment details may be lost if you continue.</string>
<string name="qr_scanner_warning_unknown_parameters_title">Unknown Parameters</string>
<string name="quick_action_buy_description">Get crypto by card, bank transfer &amp; more</string>
<string name="quick_action_buy_description">Credit card or bank account</string>
<string name="quick_action_receive_description">Share your address or QR-code</string>
<string name="quick_action_sell_description">Convert crypto to fiat currency</string>
<string name="quick_action_send_and_swap_description">Exchange and send in one step</string>
<string name="quick_action_send_description">Transfer crypto to another wallet</string>
<string name="quick_action_swap_description">Exchange one crypto for another</string>
<string name="quick_action_sell_description">Sell crypto securely</string>
<string name="quick_action_send_and_swap_description">Send with swap to another token</string>
<string name="quick_action_send_description">Send to another wallet</string>
<string name="quick_action_swap_description">Between your portfolios</string>
<string name="quick_top_up_chip_other">Other</string>
<string name="quick_top_up_title">Quick top up</string>
<string name="receive_bottom_sheet_no_memo_required_message">No memo required</string>
@ -1549,7 +1522,7 @@
<string name="send_validation_invalid_total">Total amount exceeds balance</string>
<string name="send_with_swap_change_token_alert_message">Are you sure you want to change the receiving token? This will reset your previously entered data.</string>
<string name="send_with_swap_change_token_alert_title">Changing token</string>
<string name="send_with_swap_confirm_title">Swap &amp; Send</string>
<string name="send_with_swap_confirm_title">Swap and send</string>
<string name="send_with_swap_convert_token_alert_message">Proceed with swap? This will clear your previous data.</string>
<string name="send_with_swap_convert_token_alert_title">Confirm Conversion</string>
<string name="send_with_swap_correct_recipient_network_notification_message">Sending any other currency will result in its irreversible loss.</string>
@ -1657,9 +1630,9 @@
<string name="staking_notification_ton_account_initialization_message">To begin staking, you need to activate your TON account first.</string>
<string name="staking_notification_ton_account_initialization_title">Account activation</string>
<string name="staking_notification_ton_activate_account">To start staking in TON, first send a small transaction to your own address — this will activate your wallet.</string>
<string name="staking_notification_ton_extra_reserve_info">Up to 0.2 GRAM may be required in addition to the network fee to complete the transaction. Any unused amount will be refunded.</string>
<string name="staking_notification_ton_extra_reserve_is_required">0.2 GRAM is required to proceed with this operation, in addition to the network fee. Please top up your balance.</string>
<string name="staking_notification_ton_extra_reserve_title">GRAM reserve required</string>
<string name="staking_notification_ton_extra_reserve_info">Up to 0.2 TON may be required in addition to the network fee to complete the transaction. Any unused amount will be refunded.</string>
<string name="staking_notification_ton_extra_reserve_is_required">0.2 TON is required to proceed with this operation, in addition to the network fee. Please top up your balance.</string>
<string name="staking_notification_ton_extra_reserve_title">TON reserve required</string>
<string name="staking_notification_ton_have_to_unstake_all_text">This action will close other positions or switch them to withdrawal status, according to network rules.</string>
<string name="staking_notification_ton_have_to_unstake_all_title">Positions status</string>
<string name="staking_notification_unlock_text">Unlock your money to withdraw it from staking process. Unlocking takes %s.</string>
@ -1864,9 +1837,6 @@
<string name="tangempay_card_details_add_funds">Add funds</string>
<string name="tangempay_card_details_add_funds_subtitle">Top-up options</string>
<string name="tangempay_card_details_add_to_wallet_button_text">Add to Google Wallet</string>
<string name="tangempay_card_details_awaiting_deposit_cancel_button">Cancel %1$s, move to %2$s</string>
<string name="tangempay_card_details_awaiting_deposit_subtitle">To pay monthly fee for plan and start use card</string>
<string name="tangempay_card_details_awaiting_deposit_title">Top-up your account on %1$s</string>
<string name="tangempay_card_details_card_number">Card Number</string>
<string name="tangempay_card_details_change_pin">PIN code</string>
<string name="tangempay_card_details_change_pin_success_description">The card is fully ready for payments.</string>
@ -1901,8 +1871,6 @@
<string name="tangempay_card_details_rename_card_placeholder">Card name</string>
<string name="tangempay_card_details_reveal_text">Reveal</string>
<string name="tangempay_card_details_show_details">Details</string>
<string name="tangempay_card_details_system_downgrade_subtitle">If it will remain below zero your %1$s cards will be closed on %2$s</string>
<string name="tangempay_card_details_system_downgrade_title">Top up your account shortly</string>
<string name="tangempay_card_details_title">Details</string>
<string name="tangempay_card_details_unable_to_rename_card_description">Please try again later</string>
<string name="tangempay_card_details_unfreeze_card">Unfreeze Card</string>
@ -1930,22 +1898,12 @@
<item quantity="one">%d card</item>
<item quantity="other">%d cards</item>
</plurals>
<string name="tangempay_cashback_deactivated_description">It was made due to your suspicious behavior. Contact support to learn more</string>
<string name="tangempay_cashback_deactivated_title">Cashback deactivated</string>
<string name="tangempay_cashback_deposited_on">Will be deposited on %1$s</string>
<string name="tangempay_cashback_widget_title">%1$s cashback in %2$s</string>
<string name="tangempay_change_pin_code">Change PIN-code</string>
<string name="tangempay_come_back_if_forget_pin">Come back to the app if you forget it.</string>
<string name="tangempay_common_card">Card</string>
<string name="tangempay_common_error_loading">Error loading</string>
<string name="tangempay_current_plan_active_till_notification">Your %1$s plan is active till %2$s, then we will move you to %3$s. %4$s won\'t be charged.</string>
<string name="tangempay_current_plan_change">Change plan</string>
<string name="tangempay_current_plan_fee_charged_notification">%1$s monthly fee will be charged on %2$s</string>
<string name="tangempay_current_plan_section_card">Card related</string>
<string name="tangempay_current_plan_section_plan">Plan related</string>
<string name="tangempay_current_plan_stay_button">Stay on %1$s</string>
<string name="tangempay_current_plan_stay_sheet_body">Your transition on %1$s will be canceled</string>
<string name="tangempay_current_plan_stay_sheet_title">Do you want to stay on %1$s?</string>
<string name="tangempay_current_plan_title">Current plan</string>
<string name="tangempay_daily_limit_hint" formatted="false">Set a limit from %s to %s</string>
<string name="tangempay_daily_limit_set_button">Set limits</string>
@ -2002,7 +1960,6 @@
<string name="tangempay_kyc_rejected_button_text">Hide KYC block</string>
<string name="tangempay_kyc_rejected_description">Sorry, we couldn\'t verify</string>
<string name="tangempay_kyc_rejected_description_span">your profile.</string>
<string name="tangempay_main_select_plan">Select plan</string>
<string name="tangempay_maximum_cards_issued_description">You can have up to 3 cards. Delete one to add a new card.</string>
<string name="tangempay_maximum_cards_issued_title">Maximum Cards Issued</string>
<string name="tangempay_newonboard_Q1_body">Yes to operate a regulated Visa card, identity verification is mandatory. KYC is handled by Sumsub (compliance partner).</string>
@ -2057,16 +2014,7 @@
<string name="tangempay_select_plan_btn_select">Select</string>
<string name="tangempay_select_plan_btn_upgrade">Upgrade plan</string>
<string name="tangempay_select_plan_compare">Compare plans</string>
<string name="tangempay_select_plan_confirm_downgrade_title">Your %1$s plan and %2$s cards will be active till %3$s</string>
<string name="tangempay_select_plan_confirm_point_cancel_till">You can cancel this transition till %1$s</string>
<string name="tangempay_select_plan_confirm_point_cards_closed">Your %1$s cards will be closed</string>
<string name="tangempay_select_plan_confirm_point_monthly_fee">%1$s monthly fee will be taken from your account</string>
<string name="tangempay_select_plan_confirm_point_move_on_date">On %1$s we will move you to %2$s plan</string>
<string name="tangempay_select_plan_confirm_point_no_fee">No fee applied</string>
<string name="tangempay_select_plan_confirm_point_virtual_card">You will get your virtual %1$s in minutes</string>
<string name="tangempay_select_plan_confirm_switch_title">You are switching to %1$s</string>
<string name="tangempay_select_plan_confirm_title">Confirm selection</string>
<string name="tangempay_select_plan_confirm_upgrade_title">We will issue %1$s for you</string>
<string name="tangempay_select_plan_title">Select plan</string>
<string name="tangempay_service_unavailable_description">Were fixing a technical issue. Please try again later.</string>
<string name="tangempay_service_unavailable_title">Service temporarily unavailable</string>
@ -2075,7 +2023,6 @@
<string name="tangempay_set_pin_header">Set up new PIN</string>
<string name="tangempay_set_pin_title">Set PIN</string>
<string name="tangempay_status_deactivated">Account closed</string>
<string name="tangempay_status_inactive">Inactive</string>
<string name="tangempay_status_replacing">Replacing your card</string>
<string name="tangempay_sync_needed">Use your card or ring to renew session</string>
<string name="tangempay_sync_needed_body">Use your card or ring to renew session</string>
@ -2093,7 +2040,6 @@
<string name="tangempay_usdc_on_polygon_network">USDC on Polygon network</string>
<string name="tangempay_va_banking_details_error_description">Please try again or contact support if the issue persists</string>
<string name="tangempay_va_banking_details_error_title">Couldn\'t load banking details</string>
<string name="tangempay_visa_benefits">Visa Benefits</string>
<string name="tangempay_withdrawal_note_description">Funds from refunded purchases wont be returned your on-chain Polygon balance or be available for withdrawal, but will stay on your card balance for purchases</string>
<string name="tangempay_withdrawal_note_title">Please note</string>
<string name="tangempay_your_pin_code">Your PIN code</string>
@ -2137,10 +2083,6 @@
<string name="token_receive_show_qr_code_title">Show QR code</string>
<string name="token_summary_can_not_load_token">Cant load data of the token</string>
<string name="token_summary_go_to_swap_button">Go to swap</string>
<string name="token_summary_last_update_subtitle">Last update: %1$s</string>
<string name="token_summary_negative_outlook_title">Negative outlook</string>
<string name="token_summary_neutral_outlook_title">Neutral outlook</string>
<string name="token_summary_positive_outlook_title">Positive outlook</string>
<string name="token_summary_title">Token summary</string>
<string name="token_swap_changelly_promotion_message">Exchange this token for another at %1$s service fees from February %2$s-%3$s.</string>
<string name="token_swap_changelly_promotion_title">Swap with Changelly, %s fees</string>
@ -2658,7 +2600,7 @@
<string name="yield_module_approve_sheet_fee_note">The fee will be deducted, and your assets will be resupplied.</string>
<string name="yield_module_approve_sheet_subtitle">To continue generating yield, approval is required.</string>
<string name="yield_module_approve_sheet_title">Confirm approval</string>
<string name="yield_module_average_apy">Current APY %1$s%%</string>
<string name="yield_module_average_apy">Average APY %1$s%%</string>
<string name="yield_module_balance_info_sheet_subtitle">Your funds are currently supplied to the Aave protocol, but you can manage them at any time.</string>
<string name="yield_module_balance_info_sheet_title">Your %s is supplied to Aave</string>
<string name="yield_module_chart_loading_error">Unable to load chart...</string>

View file

@ -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(

View file

@ -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

View file

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

View file

@ -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(

View file

@ -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(

View file

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

View file

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

View file

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

View file

@ -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,

View file

@ -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,
)

View file

@ -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()

View file

@ -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()

View file

@ -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<TangemPayVaBankingDetailsErrorComponent.Params>()
val uiState: StateFlow<TangemPayVaBankingDetailsErrorUM>
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) }
}
}
}
}

View file

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

View file

@ -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<TangemBottomSheetConfigContent.Empty>(
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<TangemPayVaBankingDetailsErrorUM>(
collection = listOf(
TangemPayVaBankingDetailsErrorUM(
isRetryLoading = false,
onRetryClick = {},
onContactSupportClick = {},
onDismiss = {},
),
TangemPayVaBankingDetailsErrorUM(
isRetryLoading = true,
onRetryClick = {},
onContactSupportClick = {},
onDismiss = {},
),
),
)

View file

@ -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<Either<Throwable, Unit>>()
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<PaymentAccountStatusValue.Loaded>()
every { loaded.virtualAccount } returns onramp
val status = mockk<AccountStatus.Payment>()
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,
)
}
}