Updated on 2026-08-14

This commit is contained in:
Tangem 2025-12-16 15:12:42 +03:00
parent 0ccfceba04
commit 35e0cccc90
12 changed files with 110 additions and 5 deletions

View file

@ -108,6 +108,8 @@ object PreferencesKeys {
val ONRAMP_TRANSACTIONS_STATUSES_KEY by lazy { stringPreferencesKey(name = "onrampTransactionsStatuses") } val ONRAMP_TRANSACTIONS_STATUSES_KEY by lazy { stringPreferencesKey(name = "onrampTransactionsStatuses") }
val ONRAMP_HANDLED_TRANSACTIONS_KEY by lazy { stringPreferencesKey(name = "onrampHandledTransactions") }
val ONBOARDING_FINALIZE_SCAN_RESPONSE_KEY by lazy { stringPreferencesKey(name = "onboardingFinalizeScanResponse") } val ONBOARDING_FINALIZE_SCAN_RESPONSE_KEY by lazy { stringPreferencesKey(name = "onboardingFinalizeScanResponse") }
val IS_GOOGLE_SERVICES_AVAILABLE_KEY by lazy { booleanPreferencesKey(name = "isGoogleServicesAvailable") } val IS_GOOGLE_SERVICES_AVAILABLE_KEY by lazy { booleanPreferencesKey(name = "isGoogleServicesAvailable") }

View file

@ -1,6 +1,7 @@
package com.tangem.data.onramp package com.tangem.data.onramp
import com.tangem.data.onramp.converters.TransactionConverter import com.tangem.data.onramp.converters.TransactionConverter
import com.tangem.data.onramp.models.OnrampTerminalTransactionDTO
import com.tangem.data.onramp.models.OnrampTransactionDTO import com.tangem.data.onramp.models.OnrampTransactionDTO
import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys import com.tangem.datasource.local.preferences.PreferencesKeys
@ -15,7 +16,9 @@ import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.addOrReplace import com.tangem.utils.extensions.addOrReplace
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.concurrent.TimeUnit
internal class DefaultOnrampTransactionRepository( internal class DefaultOnrampTransactionRepository(
private val appPreferencesStore: AppPreferencesStore, private val appPreferencesStore: AppPreferencesStore,
@ -47,6 +50,7 @@ internal class DefaultOnrampTransactionRepository(
override fun getAllTransactions(): Flow<List<OnrampTransaction>> { override fun getAllTransactions(): Flow<List<OnrampTransaction>> {
return appPreferencesStore return appPreferencesStore
.getObjectSet<OnrampTransactionDTO>(PreferencesKeys.ONRAMP_TRANSACTIONS_STATUSES_KEY) .getObjectSet<OnrampTransactionDTO>(PreferencesKeys.ONRAMP_TRANSACTIONS_STATUSES_KEY)
.onStart { cleanupExpiredTerminalTransactions() }
.map { transactions -> transactions.map(transactionConverter::convert) } .map { transactions -> transactions.map(transactionConverter::convert) }
} }
@ -63,6 +67,7 @@ internal class DefaultOnrampTransactionRepository(
cryptoCurrencyId: CryptoCurrency.ID, cryptoCurrencyId: CryptoCurrency.ID,
): Flow<List<OnrampTransaction>> = appPreferencesStore ): Flow<List<OnrampTransaction>> = appPreferencesStore
.getObjectSet<OnrampTransactionDTO>(PreferencesKeys.ONRAMP_TRANSACTIONS_STATUSES_KEY) .getObjectSet<OnrampTransactionDTO>(PreferencesKeys.ONRAMP_TRANSACTIONS_STATUSES_KEY)
.onStart { cleanupExpiredTerminalTransactions() }
.map { transactions -> .map { transactions ->
transactions.filter { transactions.filter {
it.userWalletId == userWalletId && it.toCurrencyId == cryptoCurrencyId.value it.userWalletId == userWalletId && it.toCurrencyId == cryptoCurrencyId.value
@ -101,4 +106,64 @@ internal class DefaultOnrampTransactionRepository(
} }
} }
} }
override suspend fun isHandledTransaction(txId: String): Boolean = withContext(dispatchers.io) {
val terminated = appPreferencesStore.getObjectSetSync<OnrampTerminalTransactionDTO>(
PreferencesKeys.ONRAMP_HANDLED_TRANSACTIONS_KEY,
)
terminated.any { it.txId == txId }
}
override suspend fun storeHandledTransaction(txId: String) {
withContext(dispatchers.io) {
appPreferencesStore.editData { mutablePreferences ->
runCatching {
val archived = mutablePreferences.getObjectSet<OnrampTerminalTransactionDTO>(
PreferencesKeys.ONRAMP_HANDLED_TRANSACTIONS_KEY,
).orEmpty().toMutableSet()
val record = OnrampTerminalTransactionDTO(
txId = txId,
terminatedAt = System.currentTimeMillis(),
)
archived.add(record)
mutablePreferences.setObjectSet(
key = PreferencesKeys.ONRAMP_HANDLED_TRANSACTIONS_KEY,
value = archived,
)
}
}
}
}
private suspend fun cleanupExpiredTerminalTransactions() {
withContext(dispatchers.io) {
appPreferencesStore.editData { mutablePreferences ->
runCatching {
val archived = mutablePreferences.getObjectSet<OnrampTerminalTransactionDTO>(
PreferencesKeys.ONRAMP_HANDLED_TRANSACTIONS_KEY,
)?.toMutableSet() ?: return@editData
val oneWeekAgo = System.currentTimeMillis() -
TimeUnit.DAYS.toMillis(HANDLED_TRANSACTIONS_RETENTION_DAYS)
val cleaned = archived
.filterTo(mutableSetOf()) { it.terminatedAt > oneWeekAgo }
if (cleaned.size != archived.size) {
mutablePreferences.setObjectSet(
key = PreferencesKeys.ONRAMP_HANDLED_TRANSACTIONS_KEY,
value = cleaned,
)
}
}
}
}
}
private companion object {
const val HANDLED_TRANSACTIONS_RETENTION_DAYS = 7L
}
} }

View file

@ -0,0 +1,18 @@
package com.tangem.data.onramp.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Model for terminal onramp transaction
*
* @property txId Transaction ID
* @property terminatedAt Termination timestamp in milliseconds
*/
@JsonClass(generateAdapter = true)
data class OnrampTerminalTransactionDTO(
@Json(name = "txId")
val txId: String,
@Json(name = "terminatedAt")
val terminatedAt: Long,
)

View file

@ -31,4 +31,6 @@ sealed class OnrampError {
) : OnrampError() ) : OnrampError()
data object PairsNotFound : OnrampError() data object PairsNotFound : OnrampError()
data object AlreadyHandledTransaction : OnrampError()
} }

View file

@ -1,6 +1,7 @@
package com.tangem.domain.onramp package com.tangem.domain.onramp
import arrow.core.Either import arrow.core.Either
import arrow.core.left
import com.tangem.domain.onramp.model.cache.OnrampTransaction import com.tangem.domain.onramp.model.cache.OnrampTransaction
import com.tangem.domain.onramp.model.error.OnrampError import com.tangem.domain.onramp.model.error.OnrampError
import com.tangem.domain.onramp.repositories.OnrampErrorResolver import com.tangem.domain.onramp.repositories.OnrampErrorResolver
@ -12,6 +13,10 @@ class GetOnrampTransactionUseCase(
) { ) {
suspend operator fun invoke(txId: String): Either<OnrampError, OnrampTransaction> { suspend operator fun invoke(txId: String): Either<OnrampError, OnrampTransaction> {
if (onrampTransactionRepository.isHandledTransaction(txId)) {
return OnrampError.AlreadyHandledTransaction.left()
}
return Either.catch { return Either.catch {
requireNotNull( requireNotNull(
onrampTransactionRepository.getTransactionById(txId), onrampTransactionRepository.getTransactionById(txId),

View file

@ -11,10 +11,13 @@ class OnrampRemoveTransactionUseCase(
private val errorResolver: OnrampErrorResolver, private val errorResolver: OnrampErrorResolver,
) { ) {
suspend operator fun invoke(txId: String?): Either<OnrampError, Unit> { suspend operator fun invoke(txId: String?, forceRemove: Boolean = false): Either<OnrampError, Unit> {
if (txId == null) return OnrampError.DomainError("Transaction id not provided").left() if (txId == null) return OnrampError.DomainError("Transaction id not provided").left()
return Either.catch { return Either.catch {
if (!forceRemove) {
onrampTransactionRepository.storeHandledTransaction(txId)
}
onrampTransactionRepository.removeTransaction(txId) onrampTransactionRepository.removeTransaction(txId)
}.mapLeft(errorResolver::resolve) }.mapLeft(errorResolver::resolve)
} }

View file

@ -24,4 +24,8 @@ interface OnrampTransactionRepository {
) )
suspend fun removeTransaction(txId: String) suspend fun removeTransaction(txId: String)
suspend fun storeHandledTransaction(txId: String)
suspend fun isHandledTransaction(txId: String): Boolean
} }

View file

@ -111,6 +111,7 @@ internal class AllOffersStateFactory(
-> true -> true
is OnrampError.AmountError, is OnrampError.AmountError,
is OnrampError.RedirectError, is OnrampError.RedirectError,
is OnrampError.AlreadyHandledTransaction,
-> false -> false
} }
} }

View file

@ -63,6 +63,7 @@ internal class OnrampStateFactory(
is OnrampError.AmountError.TooSmallError, is OnrampError.AmountError.TooSmallError,
OnrampError.RedirectError.VerificationFailed, OnrampError.RedirectError.VerificationFailed,
OnrampError.RedirectError.WrongRequestId, OnrampError.RedirectError.WrongRequestId,
OnrampError.AlreadyHandledTransaction,
-> currentStateProvider() // ignore error state -> currentStateProvider() // ignore error state
} }
} }

View file

@ -86,6 +86,7 @@ internal class OnrampV2StateFactory(
is OnrampError.AmountError.TooSmallError, is OnrampError.AmountError.TooSmallError,
OnrampError.RedirectError.VerificationFailed, OnrampError.RedirectError.VerificationFailed,
OnrampError.RedirectError.WrongRequestId, OnrampError.RedirectError.WrongRequestId,
OnrampError.AlreadyHandledTransaction,
-> currentStateProvider() // ignore error state -> currentStateProvider() // ignore error state
} }
} }

View file

@ -177,10 +177,12 @@ internal class OnrampSuccessComponentModel @Inject constructor(
private fun showErrorAlert(error: OnrampError) { private fun showErrorAlert(error: OnrampError) {
val errorCode = (error as? OnrampError.DataError)?.code val errorCode = (error as? OnrampError.DataError)?.code
val message = DialogMessage( val message = DialogMessage(
message = if (errorCode.isNullOrBlank()) { message = when {
resourceReference(R.string.common_unknown_error) !errorCode.isNullOrBlank() -> resourceReference(R.string.express_error_code, wrappedList(errorCode))
} else { error is OnrampError.AlreadyHandledTransaction -> resourceReference(
resourceReference(R.string.express_error_code, wrappedList(errorCode)) R.string.onramp_error_transaction_already_processed,
)
else -> resourceReference(R.string.common_unknown_error)
}, },
firstActionBuilder = { firstActionBuilder = {
okAction(router::pop) okAction(router::pop)

View file

@ -31,5 +31,6 @@ internal fun AnalyticsEventHandler.sendOnrampErrorEvent(
OnrampError.RedirectError.VerificationFailed, OnrampError.RedirectError.VerificationFailed,
OnrampError.RedirectError.WrongRequestId, OnrampError.RedirectError.WrongRequestId,
OnrampError.PairsNotFound, OnrampError.PairsNotFound,
OnrampError.AlreadyHandledTransaction,
-> { /* no-op */ } -> { /* no-op */ }
} }