diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/MoshiModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/MoshiModule.kt index c1e4a4f7dc..de63c7bbcd 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/MoshiModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/MoshiModule.kt @@ -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") diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusDM.kt b/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusDM.kt new file mode 100644 index 0000000000..589fb4d915 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusDM.kt @@ -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 +} \ No newline at end of file diff --git a/data/visa/build.gradle.kts b/data/visa/build.gradle.kts index 2c2683c14e..6443faf2f5 100644 --- a/data/visa/build.gradle.kts +++ b/data/visa/build.gradle.kts @@ -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) } \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusDMConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusDMConverter.kt new file mode 100644 index 0000000000..db6fbcca4f --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusDMConverter.kt @@ -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 { + + 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) + } + } +} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt index d988e0088c..565fdbe700 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt @@ -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(), + defaultValue = emptyMap(), + ), + produceFile = { context.dataStoreFile(fileName = "payment_account_statuses") }, + scope = CoroutineScope(context = dispatchers.io + SupervisorJob()), + ), + dispatchers = dispatchers, + ) + } + @Provides @Singleton fun providePaymentAccountStatusSupplier( diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/store/PaymentAccountStatusesStore.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/store/PaymentAccountStatusesStore.kt index c6cfc742e0..5b8866a09a 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/store/PaymentAccountStatusesStore.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/store/PaymentAccountStatusesStore.kt @@ -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 +internal typealias WalletIdWithPaymentStatusDM = Map + +/** + * 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, + private val persistenceDataStore: DataStore, + 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 { - 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) + } + } } } \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/kyc/KycStatus.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/kyc/KycStatus.kt index 0546090329..7297bc7c87 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/kyc/KycStatus.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/kyc/KycStatus.kt @@ -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, ;