Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-18 06:07:20 -07:00
parent 1efe0127f7
commit 6d72276898
16 changed files with 138 additions and 62 deletions

View file

@ -1,6 +1,7 @@
package com.tangem.datasource.di
import com.tangem.datasource.local.datastore.RuntimeDataStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.visa.DefaultTangemPayCardFrozenStateStore
import com.tangem.datasource.local.visa.DefaultTangemPayReissueCardStore
import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore
@ -25,9 +26,10 @@ internal object TangemPayStoresModule {
@Provides
@Singleton
fun provideTangemPayReissueCardStore(): TangemPayReissueCardStore {
fun provideTangemPayReissueCardStore(prefs: AppPreferencesStore): TangemPayReissueCardStore {
return DefaultTangemPayReissueCardStore(
feeStore = RuntimeDataStore(),
prefs = prefs,
)
}
}

View file

@ -1,11 +1,16 @@
package com.tangem.datasource.local.visa
import androidx.datastore.preferences.core.stringPreferencesKey
import com.tangem.datasource.local.datastore.RuntimeDataStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.utils.getSyncOrNull
import com.tangem.datasource.local.preferences.utils.store
import com.tangem.domain.models.pay.TangemPayReissueCardFee
import com.tangem.domain.models.wallet.UserWalletId
internal class DefaultTangemPayReissueCardStore(
private val feeStore: RuntimeDataStore<TangemPayReissueCardFee>,
private val prefs: AppPreferencesStore,
) : TangemPayReissueCardStore {
override suspend fun storeReissueFee(
@ -20,11 +25,15 @@ internal class DefaultTangemPayReissueCardStore(
}
override suspend fun storeReissueOrderId(cardId: String, orderId: String) {
// TODO v_rodionov: #[REDACTED_TASK_KEY] store orderId in app prefs
prefs.store(
key = getReissueKey(cardId),
value = orderId,
)
}
override suspend fun getOrderId(cardId: String): String? {
// TODO v_rodionov: #[REDACTED_TASK_KEY] store orderId in app prefs
return null
return prefs.getSyncOrNull(key = getReissueKey(cardId))
}
private fun getReissueKey(cardId: String) = stringPreferencesKey("tangem_pay_reissue_card_$cardId")
}

View file

@ -85,5 +85,6 @@ sealed interface PaymentAccountStatusValueDM {
@Json(name = "admin_daily_limit") val adminDailyLimit: SerializedBigDecimal?,
@Json(name = "is_frozen") val isFrozen: Boolean,
@Json(name = "last_digits") val lastDigits: String,
@Json(name = "is_reissuing") val isReissuing: Boolean,
)
}

View file

@ -1766,6 +1766,7 @@
<string name="tangempay_service_unreachable_try_later">Unable to display details. However, card payments are still working.</string>
<string name="tangempay_set_pin_code">Set \nPIN code</string>
<string name="tangempay_status_deactivated">Card deactivated</string>
<string name="tangempay_status_replacing">Replacing your card</string>
<string name="tangempay_sync_needed">Session expired</string>
<string name="tangempay_sync_needed_restore_access">Renew session</string>
<string name="tangempay_tangem_visa_card">Use USDC for everyday payments</string>

View file

@ -51,6 +51,7 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
adminDailyLimit = card.limit?.adminCardLimit?.amount,
isFrozen = card.isFrozen,
lastDigits = card.lastDigits,
isReissuing = card.isReissuing,
)
},
)
@ -104,6 +105,7 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
),
isFrozen = card.isFrozen,
lastDigits = card.lastDigits,
isReissuing = card.isReissuing,
)
},
)

View file

@ -29,6 +29,7 @@ import com.tangem.domain.pay.repository.*
import com.tangem.domain.pay.usecase.ChangeCardFrozenStateUseCase
import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase
import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase
import com.tangem.domain.pay.usecase.ReissueTangemPayCardUseCase
import com.tangem.domain.pay.usecase.SetTangemPayCardLimitUseCase
import com.tangem.domain.pay.usecase.StartTangemPayOrderPollingUseCase
import com.tangem.domain.pay.usecase.UpdateTangemPayCardNameUseCase
@ -196,5 +197,20 @@ internal interface TangemPayDataModule {
): StartTangemPayOrderPollingUseCase {
return StartTangemPayOrderPollingUseCase(cardDetailsRepository, paymentAccountStatusFetcher)
}
@Provides
fun provideReissueTangemPayCardUseCase(
reissueCardRepository: TangemPayReissueCardRepository,
startTangemPayOrderPollingUseCase: StartTangemPayOrderPollingUseCase,
appCoroutineScope: AppCoroutineScope,
paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
): ReissueTangemPayCardUseCase {
return ReissueTangemPayCardUseCase(
reissueCardRepository = reissueCardRepository,
startTangemPayOrderPollingUseCase = startTangemPayOrderPollingUseCase,
appCoroutineScope = appCoroutineScope,
paymentAccountStatusFetcher = paymentAccountStatusFetcher,
)
}
}
}

View file

@ -20,6 +20,7 @@ import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.model.TangemPayEntryPoint
import com.tangem.domain.pay.repository.CustomerOrderRepository
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.pay.repository.TangemPayReissueCardRepository
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import com.tangem.security.DeviceSecurityInfoProvider
@ -43,6 +44,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
private val dispatchers: CoroutineDispatcherProvider,
private val tangemPayCurrencyFactory: TangemPayCurrencyFactory,
private val eligibilityManager: TangemPayEligibilityManager,
private val reissueCardRepository: TangemPayReissueCardRepository,
) : PaymentAccountStatusFetcher {
private val logger = TangemLogger.withTag(TAG)
@ -254,7 +256,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
)
}
private fun CustomerInfo.mapToPaymentAccountStatus(userWalletId: UserWalletId): PaymentAccountStatusValue {
private suspend fun CustomerInfo.mapToPaymentAccountStatus(userWalletId: UserWalletId): PaymentAccountStatusValue {
val cardInfo = this.cardInfo
val productInstance = this.productInstance
@ -286,12 +288,21 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
}
}
private fun convertToContentState(
private suspend fun convertToContentState(
userWalletId: UserWalletId,
productInstance: CustomerInfo.ProductInstance,
cardInfo: CustomerInfo.CardInfo,
customerId: String,
): PaymentAccountStatusValue {
val reissueOrder = reissueCardRepository.getReissueOrderInfo(
userWalletId = userWalletId,
cardId = productInstance.cardId,
).getOrNull()
val isReissuing = reissueOrder != null &&
reissueOrder.orderStatus != OrderStatus.CANCELED &&
reissueOrder.orderStatus != OrderStatus.COMPLETED
val cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId)
return PaymentAccountStatusValue.Loaded(
source = StatusSource.ACTUAL,
@ -313,6 +324,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
),
isFrozen = productInstance.frozenState is TangemPayCardFrozenState.Frozen,
lastDigits = cardInfo.lastFourDigits,
isReissuing = isReissuing,
),
),
)

View file

@ -22,4 +22,5 @@ data class TangemPayCard(
@SerialName("limit") val limit: TangemPayCardLimitData?,
@SerialName("is_frozen") val isFrozen: Boolean,
@SerialName("last_digits") val lastDigits: String,
@SerialName("is_reissuing") val isReissuing: Boolean,
)

View file

@ -0,0 +1,33 @@
package com.tangem.domain.pay.usecase
import arrow.core.Either
import arrow.core.raise.either
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.repository.TangemPayReissueCardRepository
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.utils.coroutines.AppCoroutineScope
import kotlinx.coroutines.launch
class ReissueTangemPayCardUseCase(
private val reissueCardRepository: TangemPayReissueCardRepository,
private val startTangemPayOrderPollingUseCase: StartTangemPayOrderPollingUseCase,
private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
private val appCoroutineScope: AppCoroutineScope,
) {
suspend operator fun invoke(userWalletId: UserWalletId, cardId: String): Either<VisaApiError, Unit> = either {
val order = reissueCardRepository.reissueCard(userWalletId, cardId).bind()
if (order.orderStatus == OrderStatus.CANCELED) {
raise(VisaApiError.Unspecified)
}
reissueCardRepository.storeReissueOrderId(cardId, order.orderId)
paymentAccountStatusFetcher.invoke(userWalletId)
appCoroutineScope.launch {
startTangemPayOrderPollingUseCase(order, userWalletId)
}
}
}

View file

@ -7,7 +7,6 @@ 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.wallet.UserWalletId
import com.tangem.domain.pay.model.TangemPayOrderInfo
import com.tangem.features.tangempay.model.TangemPayReissueCardModel
import com.tangem.features.tangempay.ui.TangemPayReissueCardContent
@ -34,7 +33,6 @@ internal class TangemPayReissueCardComponent(
}
internal interface ReissueCardListener {
fun onReissueOrderCreate(order: TangemPayOrderInfo)
fun onDismissReissueCard()
fun onClickAddFunds()
}

View file

@ -28,11 +28,8 @@ import com.tangem.domain.models.account.requireCardWithId
import com.tangem.domain.models.pay.TangemPayCard
import com.tangem.domain.models.pay.TangemPayCardLimitPeriod
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.model.TangemPayOrderInfo
import com.tangem.domain.pay.model.TangemPayTopUpData
import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository
import com.tangem.domain.pay.repository.TangemPayReissueCardRepository
import com.tangem.domain.pay.usecase.ChangeCardFrozenStateUseCase
import com.tangem.domain.tangempay.TangemPayAnalyticsEvents
import com.tangem.features.tangempay.components.AddFundsListener
@ -63,7 +60,6 @@ internal class TangemPayCardPageModel @Inject constructor(
private val analytics: AnalyticsEventHandler,
private val cardDetailsRepository: TangemPayCardDetailsRepository,
private val uiMessageSender: UiMessageSender,
private val reissueCardRepository: TangemPayReissueCardRepository,
private val changeCardFrozenStateUseCase: ChangeCardFrozenStateUseCase,
) : Model(), ViewPinListener, ReissueCardListener, AddFundsListener {
@ -84,7 +80,6 @@ internal class TangemPayCardPageModel @Inject constructor(
val bottomSheetNavigation: SlotNavigation<TangemPayCardNavigation> = SlotNavigation()
// TODO v_rodionov: #[REDACTED_TASK_KEY] check reissue order state before card details are showed
init {
analytics.send(TangemPayAnalyticsEvents.CardManagementScreenOpened())
fetchAddToWalletBanner()
@ -109,7 +104,13 @@ internal class TangemPayCardPageModel @Inject constructor(
} else {
TangemPayDailyLimitBlockState.Error
}
uiState.update { it.copy(dailyLimitState = dailyLimitState, settings = buildSettings(card)) }
uiState.update { uiState ->
uiState.copy(
dailyLimitState = dailyLimitState,
settings = buildSettings(card),
isReissueInProgress = card.isReissuing,
)
}
} else {
uiState.update { it.copy(dailyLimitState = TangemPayDailyLimitBlockState.Error) }
}
@ -176,18 +177,6 @@ internal class TangemPayCardPageModel @Inject constructor(
bottomSheetNavigation.activate(TangemPayCardNavigation.ReissueCard)
}
override fun onReissueOrderCreate(order: TangemPayOrderInfo) {
bottomSheetNavigation.dismiss()
onReissueOrderStatusReceived(order.orderStatus)
if (order.orderStatus != OrderStatus.CANCELED) {
modelScope.launch {
reissueCardRepository.storeReissueOrderId(params.config.cardId, order.orderId)
}
} else {
uiMessageSender.send(SnackbarMessage(resourceReference(R.string.common_something_went_wrong)))
}
}
override fun onDismissReissueCard() {
bottomSheetNavigation.dismiss()
}
@ -314,18 +303,4 @@ internal class TangemPayCardPageModel @Inject constructor(
override fun onDismissViewPin() {
bottomSheetNavigation.dismiss()
}
private fun onReissueOrderStatusReceived(orderStatus: OrderStatus) {
when (orderStatus) {
OrderStatus.NEW, OrderStatus.PROCESSING, OrderStatus.COMPLETED -> {
uiState.update { state ->
state.copy(
addToWalletBlockState = null,
isReissueInProgress = true,
)
}
}
OrderStatus.CANCELED -> Unit
}
}
}

View file

@ -13,6 +13,7 @@ import com.tangem.core.ui.format.bigdecimal.getJavaCurrencyByCode
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository
import com.tangem.domain.pay.repository.TangemPayReissueCardRepository
import com.tangem.domain.pay.usecase.ReissueTangemPayCardUseCase
import com.tangem.domain.tangempay.TangemPayAnalyticsEvents
import com.tangem.features.tangempay.components.TangemPayReissueCardComponent
import com.tangem.features.tangempay.details.impl.R
@ -29,6 +30,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@Suppress("LongParameterList")
@Stable
@ModelScoped
internal class TangemPayReissueCardModel @Inject constructor(
@ -36,6 +38,7 @@ internal class TangemPayReissueCardModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val cardDetailsRepository: TangemPayCardDetailsRepository,
private val reissueCardRepository: TangemPayReissueCardRepository,
private val reissueTangemPayCardUseCase: ReissueTangemPayCardUseCase,
private val uiMessageSender: UiMessageSender,
private val analytics: AnalyticsEventHandler,
) : Model() {
@ -72,15 +75,13 @@ internal class TangemPayReissueCardModel @Inject constructor(
analytics.send(TangemPayAnalyticsEvents.ReplaceCardConfirmed())
state.update { it.copy(isReissuingInProgress = true) }
modelScope.launch {
reissueCardRepository.reissueCard(
reissueTangemPayCardUseCase(
userWalletId = params.userWalletId,
cardId = params.cardId,
).onLeft {
uiMessageSender.send(SnackbarMessage(resourceReference(R.string.common_something_went_wrong)))
onDismiss()
}.onRight { order ->
params.listener.onReissueOrderCreate(order)
}
onDismiss()
}.saveIn(reissueJobHolder)
}
@ -96,7 +97,7 @@ internal class TangemPayReissueCardModel @Inject constructor(
val error = if (fee == null || cardBalance == null) {
TangemPayReissueCardError.InitialDataLoading
} else if (cardBalance.availableForWithdrawal < fee.amount) {
} else if (cardBalance.fiatBalance < fee.amount) {
TangemPayReissueCardError.InsufficientFunds
} else {
null

View file

@ -24,10 +24,7 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.util.fastForEach
import com.tangem.core.ui.components.appbar.AppBarWithBackButton
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
@ -102,19 +99,6 @@ internal fun TangemPayCardPageScreen(
}
}
@Composable
private fun TangemPayReplacingCardBlock(modifier: Modifier = Modifier) {
Notification(
modifier = modifier,
config = NotificationConfig(
iconResId = com.tangem.core.ui.R.drawable.ic_update_32,
iconTint = NotificationConfig.IconTint.Accent,
title = resourceReference(R.string.tangempay_reissue_card_in_progress),
subtitle = resourceReference(R.string.tangempay_reissue_card_in_progress_description),
),
)
}
@Composable
private fun TangemPayCardPageSettingsBlock(
settings: ImmutableList<TangemPayCardPageSetting>,

View file

@ -0,0 +1,35 @@
package com.tangem.features.tangempay.ui
import android.content.res.Configuration
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.tangempay.details.impl.R
@Composable
internal fun TangemPayReplacingCardBlock(modifier: Modifier = Modifier) {
Notification(
modifier = modifier,
config = NotificationConfig(
iconResId = R.drawable.ic_update_32,
iconTint = NotificationConfig.IconTint.Accent,
title = resourceReference(R.string.tangempay_reissue_card_in_progress),
subtitle = resourceReference(R.string.tangempay_reissue_card_in_progress_description),
),
containerColor = TangemTheme.colors.background.primary,
)
}
@Preview(showBackground = true)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview() {
TangemThemePreview {
TangemPayReplacingCardBlock()
}
}

View file

@ -74,6 +74,7 @@ internal class TangemPayCardLimitSetupModelTest {
)
}
),
isReissuing = false,
)
val statusWithLimit: PaymentAccountStatusValue.Loaded = mockk(relaxed = true) {
every { source } returns StatusSource.ACTUAL

View file

@ -3,6 +3,7 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers.convert
import androidx.compose.ui.text.SpanStyle
import com.tangem.common.ui.R
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
@ -85,7 +86,11 @@ internal class TangemPayMainBlockConverter(
is PaymentAccountStatusValue.Loaded -> {
val card = statusValue.cards.firstOrNull() ?: return TangemPayMainUM.TemporaryUnavailable
TangemPayMainUM.Content(
subtitle = stringReference("*${card.lastDigits}"),
subtitle = if (card.isReissuing) {
resourceReference(R.string.tangempay_status_replacing)
} else {
stringReference("*${card.lastDigits}")
},
isBalanceFlickering = statusValue.source == StatusSource.CACHE,
balance = getBalanceText(
currencyCode = statusValue.currencyCode,