Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-09 08:02:24 -07:00
parent e1c58bed0e
commit 8d37a2bc1f
20 changed files with 470 additions and 54 deletions

View file

@ -92,13 +92,15 @@ internal class DefaultTangemPayDetailsContainerComponent @AssistedInject constru
is TangemPayAccountDetailsInnerRoute.CurrentPlan -> TangemPayCurrentPlanComponent(
appComponentContext = childByContext(componentContext = componentContext, router = innerRouter),
params = TangemPayCurrentPlanComponent.Params(
userWalletId = params.initialStatus.userWalletId,
tariffPlan = config.tariffPlan,
),
)
TangemPayAccountDetailsInnerRoute.SelectPlan -> TangemPaySelectPlanComponent(
is TangemPayAccountDetailsInnerRoute.SelectPlan -> TangemPaySelectPlanComponent(
appComponentContext = childByContext(componentContext = componentContext, router = innerRouter),
params = TangemPaySelectPlanComponent.Params(
userWalletId = params.initialStatus.userWalletId,
tariffPlan = config.tariffPlan,
),
)
TangemPayAccountDetailsInnerRoute.VirtualAccountDepositSuccess ->

View file

@ -37,7 +37,7 @@ internal class TangemPayDetailsNotificationFactory(
},
)
// TODO v_rodionov: strings hardcoded for now - wait for localization
// TODO v_rodionov: #[REDACTED_TASK_KEY] fix hardcoded strings
fun createAwaitingDepositConfig(tariffPlan: TangemPayTariffPlanState?): NotificationConfig? {
if (!isTiersPlusPlanEnabled) return null
if (tariffPlan == null) return null
@ -81,7 +81,7 @@ internal class TangemPayDetailsNotificationFactory(
iconResId = if (isRedesignEnabled) R.drawable.ic_alert_circle_24 else R.drawable.img_attention_20,
)
// TODO v_rodionov: strings hardcoded for now - wait for localization
// TODO v_rodionov: #[REDACTED_TASK_KEY] fix hardcoded strings
private fun createTariffSystemDownGradePendingConfig(tariffPlan: TangemPayTariffPlanState): NotificationConfig? {
val date = tariffPlan.tariff.formatNextBillingDateOrNull() ?: return null
val planName = tariffPlan.tariff.plan.name

View file

@ -22,7 +22,9 @@ internal sealed class TangemPayAccountDetailsInnerRoute : Route {
) : TangemPayAccountDetailsInnerRoute()
@Serializable
data object SelectPlan : TangemPayAccountDetailsInnerRoute()
data class SelectPlan(
val tariffPlan: TangemPayCustomerTariffPlan,
) : TangemPayAccountDetailsInnerRoute()
@Serializable
data object VirtualAccountDepositSuccess : TangemPayAccountDetailsInnerRoute()

View file

@ -8,6 +8,7 @@ import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.account.TangemPayCustomerTariffPlan
import com.tangem.domain.models.wallet.UserWalletId
internal class TangemPayCurrentPlanComponent(
appComponentContext: AppComponentContext,
@ -22,5 +23,8 @@ internal class TangemPayCurrentPlanComponent(
TangemPayCurrentPlanScreen(state = state, modifier = modifier)
}
data class Params(val tariffPlan: TangemPayCustomerTariffPlan)
data class Params(
val userWalletId: UserWalletId,
val tariffPlan: TangemPayCustomerTariffPlan,
)
}

View file

@ -7,18 +7,22 @@ import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.utils.DateTimeFormatters
import com.tangem.domain.models.account.TangemPayCustomerTariffPlan
import com.tangem.domain.models.account.TangemPayTariffPlan
import com.tangem.domain.pay.usecase.CancelTariffPlanPendingTransitionUseCase
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.navigation.TangemPayAccountDetailsInnerRoute
import com.tangem.features.tangempay.tiers.formatNextBillingDateOrNull
import com.tangem.features.tangempay.tiers.formatRecurringFeeOrNull
import com.tangem.features.tangempay.utils.TangemPayMessagesFactory
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@Stable
@ -27,10 +31,14 @@ internal class TangemPayCurrentPlanModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val router: Router,
private val cancelPendingTransition: CancelTariffPlanPendingTransitionUseCase,
private val uiMessageSender: UiMessageSender,
) : Model() {
private val params = paramsContainer.require<TangemPayCurrentPlanComponent.Params>()
private var isProcessing: Boolean = false
val state: StateFlow<TangemPayCurrentPlanUM>
field = MutableStateFlow(createState(params.tariffPlan))
@ -39,10 +47,10 @@ internal class TangemPayCurrentPlanModel @Inject constructor(
notification = createNotification(customerPlan),
sections = buildSections(customerPlan.plan),
onBackClick = router::pop,
onChangePlanClick = { router.push(TangemPayAccountDetailsInnerRoute.SelectPlan) },
onChangePlanClick = { router.push(TangemPayAccountDetailsInnerRoute.SelectPlan(params.tariffPlan)) },
)
// TODO v_rodionov: strings hardcoded for now - wait for localization
// TODO v_rodionov: #[REDACTED_TASK_KEY] fix hardcoded strings
private fun createNotification(customerPlan: TangemPayCustomerTariffPlan): TangemPayCurrentPlanUM.Notification? {
val date = customerPlan.formatNextBillingDateOrNull(formatter = DateTimeFormatters.dateMMMd) ?: return null
val feeText = customerPlan.plan.formatRecurringFeeOrNull() ?: return null
@ -56,7 +64,8 @@ internal class TangemPayCurrentPlanModel @Inject constructor(
),
button = TangemPayCurrentPlanUM.Notification.Button(
text = stringReference("Stay on ${customerPlan.plan.name}"),
onClick = {}, // TODO v_rodionov: #[REDACTED_TASK_KEY] - Downgrade
isProcessing = isProcessing,
onClick = ::onStayOnPlanClick,
),
)
}
@ -67,6 +76,35 @@ internal class TangemPayCurrentPlanModel @Inject constructor(
}
}
private fun onStayOnPlanClick() {
if (isProcessing) return
val customerPlan = params.tariffPlan
val targetPlanName = customerPlan.pendingPlan?.name ?: return
uiMessageSender.send(
message = TangemPayMessagesFactory.createStayOnPlanMessage(
planName = customerPlan.plan.name,
targetPlanName = targetPlanName,
onStayClick = ::confirmStayOnPlan,
),
)
}
private fun confirmStayOnPlan() {
if (isProcessing) return
isProcessing = true
state.value = createState(params.tariffPlan)
modelScope.launch {
cancelPendingTransition(params.userWalletId).fold(
ifRight = { router.pop() },
ifLeft = {
isProcessing = false
state.value = createState(params.tariffPlan)
uiMessageSender.send(message = TangemPayMessagesFactory.createGenericError())
},
)
}
}
private fun buildSections(plan: TangemPayTariffPlan) = persistentListOf(
sectionOf(plan, TangemPayTariffPlan.Section.CARD_RELATED, R.string.tangempay_current_plan_section_card),
sectionOf(plan, TangemPayTariffPlan.Section.PLAN_RELATED, R.string.tangempay_current_plan_section_plan),

View file

@ -109,6 +109,7 @@ private fun PlanNotification(notification: TangemPayCurrentPlanUM.Notification,
variant = TangemButton.Variant.Secondary,
size = TangemButton.Size.X11,
text = button.text,
isLoading = button.isProcessing,
onClick = button.onClick,
)
}
@ -205,6 +206,7 @@ private fun previewState() = TangemPayCurrentPlanUM(
),
button = TangemPayCurrentPlanUM.Notification.Button(
text = stringReference("Stay on Plus"),
isProcessing = false,
onClick = {},
),
),

View file

@ -20,6 +20,7 @@ internal data class TangemPayCurrentPlanUM(
@Immutable
data class Button(
val text: TextReference,
val isProcessing: Boolean,
val onClick: () -> Unit,
)
}

View file

@ -7,6 +7,7 @@ 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.ComposableContentComponent
import com.tangem.domain.models.account.TangemPayCustomerTariffPlan
import com.tangem.domain.models.wallet.UserWalletId
internal class TangemPaySelectPlanComponent(
@ -22,5 +23,8 @@ internal class TangemPaySelectPlanComponent(
TangemPaySelectPlanScreen(state = state, modifier = modifier)
}
data class Params(val userWalletId: UserWalletId)
data class Params(
val userWalletId: UserWalletId,
val tariffPlan: TangemPayCustomerTariffPlan,
)
}

View file

@ -1,6 +1,7 @@
package com.tangem.features.tangempay.tiers.select
import androidx.compose.runtime.Stable
import arrow.core.Either
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
@ -8,12 +9,16 @@ import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.utils.DateTimeFormatters
import com.tangem.domain.models.account.TangemPayTariffPlan
import com.tangem.domain.models.account.TangemPayTariffPlanTransition
import com.tangem.domain.pay.usecase.CreateTariffPlanTransitionOrderUseCase
import com.tangem.domain.pay.usecase.GetTangemPayTariffPlanTransitionsUseCase
import com.tangem.domain.pay.usecase.SetTariffPlanPendingTransitionUseCase
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.navigation.TangemPayAccountDetailsInnerRoute
import com.tangem.features.tangempay.tiers.formatNextBillingDateOrNull
import com.tangem.features.tangempay.tiers.formatRecurringFeeOrNull
import com.tangem.features.tangempay.utils.TangemPayMessagesFactory
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -25,6 +30,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@Suppress("LongParameterList")
@Stable
@ModelScoped
internal class TangemPaySelectPlanModel @Inject constructor(
@ -33,6 +39,7 @@ internal class TangemPaySelectPlanModel @Inject constructor(
private val router: Router,
private val getTransitions: GetTangemPayTariffPlanTransitionsUseCase,
private val createTransitionOrder: CreateTariffPlanTransitionOrderUseCase,
private val setPendingTransition: SetTariffPlanPendingTransitionUseCase,
private val uiMessageSender: UiMessageSender,
) : Model() {
@ -95,21 +102,34 @@ internal class TangemPaySelectPlanModel @Inject constructor(
}
private fun onConfirmClick() {
val transition = allowedTransitions.getOrNull(selectedIndex) ?: return
// TODO v_rodionov: #[REDACTED_TASK_KEY] - Downgrade
if (transition.type != TangemPayTariffPlanTransition.Type.UPGRADE) return
if (isProcessing) return
val transition = allowedTransitions.getOrNull(selectedIndex) ?: return
when (transition.type) {
TangemPayTariffPlanTransition.Type.ACTIVATION,
TangemPayTariffPlanTransition.Type.UPGRADE,
-> submitTransition {
createTransitionOrder(
userWalletId = params.userWalletId,
targetTariffPlanId = transition.plan.id,
transitionType = transition.type,
)
}
TangemPayTariffPlanTransition.Type.DOWNGRADE -> submitTransition {
setPendingTransition(
userWalletId = params.userWalletId,
pendingTariffPlanId = transition.plan.id,
)
}
else -> Unit
}
}
private fun submitTransition(action: suspend () -> Either<VisaApiError, Unit>) {
isProcessing = true
state.update { buildState() }
modelScope.launch {
createTransitionOrder(
userWalletId = params.userWalletId,
targetTariffPlanId = transition.plan.id,
transitionType = transition.type,
).fold(
action().fold(
ifRight = { router.popTo(TangemPayAccountDetailsInnerRoute.AccountDetails) },
ifLeft = {
isProcessing = false
@ -121,11 +141,13 @@ internal class TangemPaySelectPlanModel @Inject constructor(
}
private fun buildState(showPlanCompare: Boolean = false): TangemPaySelectPlanUM = TangemPaySelectPlanUM(
topBarTitle = if (isConfirm) {
resourceReference(R.string.tangempay_select_plan_confirm_title)
} else {
resourceReference(R.string.tangempay_select_plan_title)
},
topBarTitle = resourceReference(
if (isConfirm) {
R.string.tangempay_select_plan_confirm_title
} else {
R.string.tangempay_select_plan_title
},
),
plans = allowedTransitions.map { it.plan.toPlanUM() }.toImmutableList(),
selectedIndex = selectedIndex,
onPlanSelected = ::onPlanSelected,
@ -141,7 +163,7 @@ internal class TangemPaySelectPlanModel @Inject constructor(
)
private fun buildCompare(): TangemPaySelectPlanUM.ComparePlans {
val plans = transitions.map { it.plan }
val plans = listOf(params.tariffPlan.plan) + transitions.map { it.plan }
val orderedTitles = plans
.flatMap { plan -> plan.descriptionItems.filter { it.section in COMPARE_SECTIONS } }
.sortedWith(compareBy({ it.section.ordinal }, { it.order }))
@ -164,19 +186,35 @@ internal class TangemPaySelectPlanModel @Inject constructor(
)
}
// TODO v_rodionov: #[REDACTED_TASK_KEY] fix hardcoded strings
private fun buildConfirmContent(): TangemPaySelectPlanUM.Content {
val transition = allowedTransitions.getOrNull(selectedIndex) ?: return buildSelectContent()
val planName = transition.plan.name
val isUpgrade = transition.type == TangemPayTariffPlanTransition.Type.UPGRADE
val targetProgramme = transition.plan.name
return TangemPaySelectPlanUM.Content.Confirm(
// TODO v_rodionov: strings hardcoded for now - wait for documentation update
title = stringReference("We will issue Visa $planName for you"),
points = buildConfirmPoints(transition, planName, isUpgrade),
title = when (transition.type) {
TangemPayTariffPlanTransition.Type.UPGRADE -> stringReference(
"We will issue Visa $targetProgramme for you",
)
TangemPayTariffPlanTransition.Type.DOWNGRADE -> {
val nextBillingDate = nextBillingDate()
if (nextBillingDate != null) {
val programName = "UNKNOWN" // TODO v_rodionov: #[REDACTED_TASK_KEY] fix hardcoded strings
val planName = params.tariffPlan.plan.name
stringReference(
"Your $planName plan and $programName cards will be active till $nextBillingDate",
)
} else {
stringReference("You are switching to $targetProgramme")
}
}
else -> stringReference("You are switching to $targetProgramme")
},
points = buildConfirmPoints(transition),
confirmButtonText = resourceReference(
if (isUpgrade) {
R.string.tangempay_select_plan_btn_upgrade
} else {
R.string.tangempay_select_plan_btn_downgrade
when (transition.type) {
TangemPayTariffPlanTransition.Type.UPGRADE -> R.string.tangempay_select_plan_btn_upgrade
TangemPayTariffPlanTransition.Type.DOWNGRADE -> R.string.tangempay_select_plan_btn_downgrade
else -> R.string.common_continue
},
),
isProcessing = isProcessing,
@ -185,29 +223,39 @@ internal class TangemPaySelectPlanModel @Inject constructor(
)
}
// TODO v_rodionov: strings hardcoded for now - wait for documentation update
// TODO v_rodionov: #[REDACTED_TASK_KEY] fix hardcoded strings
private fun buildConfirmPoints(
transition: TangemPayTariffPlanTransition,
planName: String,
isUpgrade: Boolean,
): ImmutableList<TangemPaySelectPlanUM.PointUM> = if (isUpgrade) {
val feeText = transition.plan.formatRecurringFeeOrNull()
listOf(
stringReference("You will get your virtual Visa $planName in minutes"),
if (feeText != null) {
stringReference("$feeText monthly fee will be taken from your account")
} else {
stringReference("No fee applied")
},
)
} else {
listOf(
stringReference("Your current Visa cards will be closed"),
stringReference("No fee applied"),
)
): ImmutableList<TangemPaySelectPlanUM.PointUM> {
val programName = "UNKNOWN" // TODO v_rodionov: #[REDACTED_TASK_KEY] fix hardcoded strings
return when (transition.type) {
TangemPayTariffPlanTransition.Type.UPGRADE -> {
val feeText = transition.plan.formatRecurringFeeOrNull()
buildList {
add("You will get your virtual Visa $programName in minutes")
if (feeText != null) {
add("$feeText monthly fee will be taken from your account")
}
}
}
TangemPayTariffPlanTransition.Type.DOWNGRADE -> {
val date = nextBillingDate()
buildList {
if (date != null) {
add("On $date we will move you to ${transition.plan.name} plan")
}
add("Your Visa $programName cards will be closed")
if (date != null) {
add("You can cancel this transition till $date")
}
add("No fee applied")
}
}
else -> listOf("No fee applied")
}
.map { TangemPaySelectPlanUM.PointUM(title = stringReference(it), body = null) }
.toImmutableList()
}
.map { TangemPaySelectPlanUM.PointUM(title = it, body = null) }
.toImmutableList()
private fun TangemPayTariffPlan.toPlanUM() = TangemPaySelectPlanUM.PlanUM(
name = stringReference(name),
@ -224,10 +272,15 @@ internal class TangemPaySelectPlanModel @Inject constructor(
.toImmutableList(),
)
private fun nextBillingDate(): String? {
return params.tariffPlan.formatNextBillingDateOrNull(DateTimeFormatters.dateMMMd)
}
companion object {
private val ALLOWED_TYPES = setOf(
TangemPayTariffPlanTransition.Type.UPGRADE,
TangemPayTariffPlanTransition.Type.DOWNGRADE,
TangemPayTariffPlanTransition.Type.ACTIVATION,
)
private val COMPARE_SECTIONS = setOf(
TangemPayTariffPlan.Section.CARD_RELATED,

View file

@ -4,6 +4,7 @@ import com.tangem.core.ui.R
import com.tangem.core.ui.components.bottomsheets.message.*
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.extensions.wrappedList
import com.tangem.core.ui.message.BottomSheetMessage
import com.tangem.core.ui.message.DialogMessage
@ -170,6 +171,35 @@ internal object TangemPayMessagesFactory {
}
}
// TODO v_rodionov: #[REDACTED_TASK_KEY] fix hardcoded strings
fun createStayOnPlanMessage(
planName: String,
targetPlanName: String,
onStayClick: () -> Unit,
): BottomSheetMessage {
return bottomSheetMessage {
infoBlock {
icon(R.drawable.ic_heart_20) {
type = MessageBottomSheetUM.Icon.Type.Informative
backgroundType = MessageBottomSheetUM.Icon.BackgroundType.Informative
}
title = stringReference("Do you want to stay on $planName?")
body = stringReference("Your transition on $targetPlanName will be canceled")
}
secondaryButton {
text = resourceReference(R.string.common_cancel)
onClick { closeBs() }
}
primaryButton {
text = stringReference("Stay on $planName")
onClick {
onStayClick()
closeBs()
}
}
}
}
fun createGenericError(): DialogMessage {
return DialogMessage(
title = resourceReference(R.string.common_something_went_wrong),