Updated on 2026-08-14

This commit is contained in:
Tangem 2026-02-25 16:00:39 +05:00
parent a0ba5ab9d9
commit 286e28223c
7 changed files with 248 additions and 14 deletions

View file

@ -9,6 +9,7 @@ import com.tangem.common.json.MoshiJsonConverter
import com.tangem.datasource.api.common.adapter.*
import com.tangem.datasource.local.config.providers.models.ProviderModel
import com.tangem.datasource.local.network.entity.NetworkStatusDM
import com.tangem.datasource.local.visa.entity.PaymentAccountStatusDM
import com.tangem.datasource.utils.SerializeNullsFactory
import com.tangem.domain.models.scan.serialization.*
import dagger.Module
@ -45,6 +46,15 @@ class MoshiModule {
.withSubtype(NetworkStatusDM.Verified::class.java, "amounts")
.withSubtype(NetworkStatusDM.NoAccount::class.java, "amount_to_create_account"),
)
.add(
NamePolymorphicAdapterFactory.of(PaymentAccountStatusDM::class.java)
.withSubtype(PaymentAccountStatusDM.NotCreated::class.java, "not_created")
.withSubtype(PaymentAccountStatusDM.UnderReview::class.java, "kyc_status")
.withSubtype(PaymentAccountStatusDM.IssuingCard::class.java, "issuing_card")
.withSubtype(PaymentAccountStatusDM.Locked::class.java, "locked")
.withSubtype(PaymentAccountStatusDM.Loaded::class.java, "balance")
.withSubtype(PaymentAccountStatusDM.CardIssueFailed::class.java, "card_issue_failed"),
)
.add(
PolymorphicJsonAdapterFactory.of(NFTCollection.Identifier::class.java, "bc")
.withSubtype(NFTCollection.Identifier.EVM::class.java, "evm")

View file

@ -0,0 +1,53 @@
@file:Suppress("BooleanPropertyNaming")
package com.tangem.datasource.local.visa.entity
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.tangem.domain.models.kyc.KycStatus
import dev.onenowy.moshipolymorphicadapter.PolymorphicAdapterType
import dev.onenowy.moshipolymorphicadapter.annotations.NameLabel
import java.math.BigDecimal
/**
* Payment account status for storage in the local cache.
*
* @see [com.tangem.domain.pay.PaymentAccountStatus]
*/
@JsonClass(generateAdapter = true, generator = PolymorphicAdapterType.NAME_POLYMORPHIC_ADAPTER)
sealed interface PaymentAccountStatusDM {
@NameLabel("not_created")
data class NotCreated(
@Json(name = "not_created") val marker: Boolean = true,
) : PaymentAccountStatusDM
@NameLabel("kyc_status")
data class UnderReview(
@Json(name = "kyc_status") val kycStatus: KycStatus,
) : PaymentAccountStatusDM
@NameLabel("issuing_card")
data class IssuingCard(
@Json(name = "issuing_card") val marker: Boolean = true,
) : PaymentAccountStatusDM
@NameLabel("locked")
data class Locked(
@Json(name = "locked") val marker: Boolean = true,
) : PaymentAccountStatusDM
@NameLabel("balance")
data class Loaded(
@Json(name = "card_id") val cardId: String,
@Json(name = "last_four_digits") val lastFourDigits: String,
@Json(name = "balance") val balance: BigDecimal,
@Json(name = "currency_code") val currencyCode: String,
@Json(name = "deposit_address") val depositAddress: String?,
@Json(name = "is_pin_set") val isPinSet: Boolean,
) : PaymentAccountStatusDM
@NameLabel("card_issue_failed")
data class CardIssueFailed(
@Json(name = "card_issue_failed") val marker: Boolean = true,
) : PaymentAccountStatusDM
}

View file

@ -38,9 +38,6 @@ dependencies {
implementation(projects.domain.common)
implementation(projects.features.swap.domain)
/** Feature API - remove after removing [HotWalletFeatureToggles] */
implementation(projects.features.hotWallet.api)
/** Project - Utils */
implementation(projects.core.utils)
@ -51,6 +48,7 @@ dependencies {
implementation(projects.libs.visa)
/** Libs - Other */
implementation(deps.androidx.datastore)
implementation(deps.kotlin.coroutines)
implementation(deps.arrow.core)
implementation(deps.arrow.fx)
@ -68,6 +66,6 @@ dependencies {
implementation(projects.libs.tangemSdkApi)
/** DI */
implementation(deps.hilt.core)
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
}

View file

@ -0,0 +1,67 @@
package com.tangem.data.pay.converter
import com.tangem.data.pay.converter.PaymentAccountStatusDMConverter.convert
import com.tangem.data.pay.converter.PaymentAccountStatusDMConverter.convertBack
import com.tangem.datasource.local.visa.entity.PaymentAccountStatusDM
import com.tangem.domain.models.StatusSource
import com.tangem.domain.pay.PaymentAccountStatus
import com.tangem.utils.converter.TwoWayConverter
/**
* Two-way converter between [PaymentAccountStatus] and [PaymentAccountStatusDM].
*
* [convert] maps domain data model. Returns null for transient statuses that should not be persisted
* (Loading, ExposedDevice, Unavailable, NotSynced).
*
* [convertBack] maps data model domain. All restored statuses have [StatusSource.CACHE] as source.
*/
internal object PaymentAccountStatusDMConverter :
TwoWayConverter<PaymentAccountStatus, PaymentAccountStatusDM?> {
override fun convert(value: PaymentAccountStatus): PaymentAccountStatusDM? {
return when (value) {
is PaymentAccountStatus.NotCreated -> PaymentAccountStatusDM.NotCreated()
is PaymentAccountStatus.UnderReview -> PaymentAccountStatusDM.UnderReview(kycStatus = value.kycStatus)
is PaymentAccountStatus.IssuingCard -> PaymentAccountStatusDM.IssuingCard()
is PaymentAccountStatus.Locked -> PaymentAccountStatusDM.Locked()
is PaymentAccountStatus.Loaded -> PaymentAccountStatusDM.Loaded(
cardId = value.cardId,
lastFourDigits = value.lastFourDigits,
balance = value.balance,
currencyCode = value.currencyCode,
depositAddress = value.depositAddress,
isPinSet = value.isPinSet,
)
is PaymentAccountStatus.Error.CardIssueFailed -> PaymentAccountStatusDM.CardIssueFailed()
// Transient statuses are not persisted
is PaymentAccountStatus.Loading,
is PaymentAccountStatus.Error.ExposedDevice,
is PaymentAccountStatus.Error.Unavailable,
is PaymentAccountStatus.Error.NotSynced,
-> null
}
}
override fun convertBack(value: PaymentAccountStatusDM?): PaymentAccountStatus {
return when (value) {
is PaymentAccountStatusDM.CardIssueFailed -> PaymentAccountStatus.Error.CardIssueFailed
is PaymentAccountStatusDM.NotCreated -> PaymentAccountStatus.NotCreated
is PaymentAccountStatusDM.IssuingCard -> PaymentAccountStatus.IssuingCard(source = StatusSource.CACHE)
is PaymentAccountStatusDM.Locked -> PaymentAccountStatus.Locked(source = StatusSource.CACHE)
is PaymentAccountStatusDM.UnderReview -> PaymentAccountStatus.UnderReview(
source = StatusSource.CACHE,
kycStatus = value.kycStatus,
)
is PaymentAccountStatusDM.Loaded -> PaymentAccountStatus.Loaded(
source = StatusSource.CACHE,
cardId = value.cardId,
lastFourDigits = value.lastFourDigits,
balance = value.balance,
currencyCode = value.currencyCode,
depositAddress = value.depositAddress,
isPinSet = value.isPinSet,
)
null -> PaymentAccountStatus.Error.Unavailable(source = StatusSource.CACHE)
}
}
}

View file

@ -1,13 +1,23 @@
package com.tangem.data.pay.di
import android.content.Context
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.dataStoreFile
import com.squareup.moshi.Moshi
import com.tangem.data.pay.DefaultTangemPayCryptoCurrencyFactory
import com.tangem.data.pay.DefaultTangemPayEligibilityManager
import com.tangem.data.pay.flow.DefaultPaymentAccountStatusFetcher
import com.tangem.data.pay.flow.DefaultPaymentAccountStatusProducer
import com.tangem.data.pay.repository.*
import com.tangem.data.pay.store.PaymentAccountStatusesStore
import com.tangem.data.pay.usecase.DefaultGetTangemPayCurrencyStatusUseCase
import com.tangem.data.pay.usecase.DefaultGetTangemPayCustomerIdUseCase
import com.tangem.data.pay.usecase.DefaultTangemPayWithdrawUseCase
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.visa.entity.PaymentAccountStatusDM
import com.tangem.datasource.utils.MoshiDataStoreSerializer
import com.tangem.datasource.utils.mapWithStringKeyTypes
import com.tangem.domain.pay.TangemPayCryptoCurrencyFactory
import com.tangem.domain.pay.TangemPayEligibilityManager
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
@ -21,11 +31,15 @@ import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase
import com.tangem.domain.tangempay.TangemPayWithdrawUseCase
import com.tangem.domain.tangempay.repository.TangemPayTxHistoryRepository
import com.tangem.security.DeviceSecurityInfoProvider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import javax.inject.Singleton
@Module
@ -92,6 +106,28 @@ internal interface TangemPayDataModule {
companion object {
@Provides
@Singleton
fun providePaymentAccountStatusesStore(
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
dispatchers: CoroutineDispatcherProvider,
): PaymentAccountStatusesStore {
return PaymentAccountStatusesStore(
runtimeStore = RuntimeSharedStore(),
persistenceDataStore = DataStoreFactory.create(
serializer = MoshiDataStoreSerializer(
moshi = moshi,
types = mapWithStringKeyTypes<PaymentAccountStatusDM>(),
defaultValue = emptyMap(),
),
produceFile = { context.dataStoreFile(fileName = "payment_account_statuses") },
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
),
dispatchers = dispatchers,
)
}
@Provides
@Singleton
fun providePaymentAccountStatusSupplier(

View file

@ -1,24 +1,86 @@
package com.tangem.data.pay.store
import androidx.datastore.core.DataStore
import com.tangem.data.pay.converter.PaymentAccountStatusDMConverter
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.visa.entity.PaymentAccountStatusDM
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.PaymentAccountStatus
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.launch
import timber.log.Timber
@Suppress("UnusedParameter", "EmptyFunctionBlock", "FunctionOnlyReturningConstant")
@Singleton
internal class PaymentAccountStatusesStore @Inject constructor() {
internal typealias WalletIdWithPaymentStatus = Map<String, PaymentAccountStatus>
internal typealias WalletIdWithPaymentStatusDM = Map<String, PaymentAccountStatusDM>
/**
* Store for payment account statuses with dual storage (runtime + persistence).
*
* @property runtimeStore runtime store for fast in-memory access
* @property persistenceDataStore persistence store for caching across app restarts
*/
internal class PaymentAccountStatusesStore(
private val runtimeStore: RuntimeSharedStore<WalletIdWithPaymentStatus>,
private val persistenceDataStore: DataStore<WalletIdWithPaymentStatusDM>,
dispatchers: CoroutineDispatcherProvider,
) {
private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io)
init {
scope.launch {
try {
val cachedStatuses = persistenceDataStore.data.firstOrNull() ?: return@launch
runtimeStore.store(
value = cachedStatuses.mapValues { (_, statusDM) ->
PaymentAccountStatusDMConverter.convertBack(statusDM)
},
)
} catch (e: Exception) {
Timber.e(e, "Error while loading cached payment account statuses")
}
}
}
fun get(userWalletId: UserWalletId): Flow<PaymentAccountStatus> {
return emptyFlow()
return runtimeStore.get().mapNotNull { it[userWalletId.stringValue] }
}
fun getSyncOrNull(userWalletId: UserWalletId): PaymentAccountStatus? {
return null
suspend fun getSyncOrNull(userWalletId: UserWalletId): PaymentAccountStatus? {
return runtimeStore.getSyncOrNull()?.get(userWalletId.stringValue)
}
fun store(userWalletId: UserWalletId, status: PaymentAccountStatus) {
suspend fun store(userWalletId: UserWalletId, status: PaymentAccountStatus) {
coroutineScope {
launch { storeInRuntime(userWalletId = userWalletId, status = status) }
launch { storeInPersistence(userWalletId = userWalletId, status = status) }
}
}
suspend fun contains(userWalletId: UserWalletId): Boolean {
return runtimeStore.getSyncOrDefault(emptyMap()).containsKey(userWalletId.stringValue)
}
private suspend fun storeInRuntime(userWalletId: UserWalletId, status: PaymentAccountStatus) {
runtimeStore.update(default = emptyMap()) { stored ->
stored.toMutableMap().apply {
put(key = userWalletId.stringValue, value = status)
}
}
}
private suspend fun storeInPersistence(userWalletId: UserWalletId, status: PaymentAccountStatus) {
val statusDM = PaymentAccountStatusDMConverter.convert(value = status) ?: return
persistenceDataStore.updateData { storedStatuses ->
storedStatuses.toMutableMap().apply {
put(key = userWalletId.stringValue, value = statusDM)
}
}
}
}

View file

@ -1,20 +1,28 @@
package com.tangem.domain.models.kyc
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
private const val APPROVED_KYC_STATUS = "approved"
private const val IN_PROGRESS_KYC_STATUS = "in_progress"
private const val DECLINED_KYC_STATUS = "declined"
@JsonClass(generateAdapter = false)
enum class KycStatus {
/** Initial state */
@Json(name = "init")
INIT,
/** Performing the check */
@Json(name = "in_progress")
PENDING,
/** SumSub approved */
@Json(name = "approved")
APPROVED,
/** The check failed, documents rejected */
@Json(name = "declined")
REJECTED,
;