From 4390b5923148257624428d4c7d9c4732aa121592 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 4 Jun 2026 14:14:44 +0400 Subject: [PATCH] Updated on 2026-08-14 --- .../1.json | 404 ++++++++++++------ .../api/express/TangemExpressApi.kt | 13 +- .../response/ExchangeHistoryResponse.kt | 89 +--- .../models/response/ExchangeItemResponse.kt | 156 +++++++ .../models/response/ExpressPagination.kt | 22 + .../tangem/datasource/api/onramp/OnrampApi.kt | 14 +- .../models/response/OnrampHistoryResponse.kt | 93 +--- .../models/response/OnrampItemResponse.kt | 156 +++++++ .../tangem/datasource/di/TxHistoryModule.kt | 47 +- .../local/txhistory/db/TxHistoryDatabase.kt | 7 +- .../db/{entity => dao}/ExpressHistoryDao.kt | 17 +- .../txhistory/db/dao/ExpressSyncStateDao.kt | 26 ++ .../entity/express/ExpressExchangeEntity.kt | 167 +++++--- .../db/entity/express/ExpressOnrampEntity.kt | 170 +++++--- .../entity/express/ExpressSyncStateEntity.kt | 39 ++ .../txhistory/store/DefaultTxHistoryStore.kt | 38 -- .../local/txhistory/store/SyncStateModel.kt | 27 -- .../local/txhistory/store/TxHistoryStore.kt | 12 - .../repository/ExpressHistoryRepository.kt | 103 +++++ .../converter/ExpressHistoryConverter.kt | 91 ++++ .../DefaultAccountTxHistoryFetcherTest.kt | 2 +- .../fetcher/DefaultAppTxHistoryFetcherTest.kt | 2 +- .../DefaultWalletTxHistoryFetcherTest.kt | 2 +- .../ExpressHistoryRepositoryTest.kt | 367 ++++++++++++++++ .../converter/ExpressHistoryConverterTest.kt | 316 ++++++++++++++ 25 files changed, 1810 insertions(+), 570 deletions(-) create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeItemResponse.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExpressPagination.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/onramp/models/response/OnrampItemResponse.kt rename core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/{entity => dao}/ExpressHistoryDao.kt (83%) create mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/dao/ExpressSyncStateDao.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/entity/express/ExpressSyncStateEntity.kt delete mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/txhistory/store/DefaultTxHistoryStore.kt delete mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/txhistory/store/SyncStateModel.kt delete mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/txhistory/store/TxHistoryStore.kt create mode 100644 data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/ExpressHistoryRepository.kt create mode 100644 data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/converter/ExpressHistoryConverter.kt create mode 100644 data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/ExpressHistoryRepositoryTest.kt create mode 100644 data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/converter/ExpressHistoryConverterTest.kt diff --git a/core/datasource/schemas/com.tangem.datasource.local.txhistory.db.TxHistoryDatabase/1.json b/core/datasource/schemas/com.tangem.datasource.local.txhistory.db.TxHistoryDatabase/1.json index dd3a028439..08c1bada0c 100644 --- a/core/datasource/schemas/com.tangem.datasource.local.txhistory.db.TxHistoryDatabase/1.json +++ b/core/datasource/schemas/com.tangem.datasource.local.txhistory.db.TxHistoryDatabase/1.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "aafa8b51b5a5a32d0ec2b0720cec6c1e", + "identityHash": "942246bf975439606ad20e05b930827c", "entities": [ { "tableName": "express_provider", @@ -44,7 +44,7 @@ }, { "tableName": "express_exchange", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tx_id` TEXT NOT NULL, `owner_address` TEXT NOT NULL, `provider_id` TEXT NOT NULL, `status` TEXT NOT NULL, `to_is_actual` INTEGER NOT NULL DEFAULT 0, `payin_hash` TEXT, `payout_hash` TEXT, `external_tx_id` TEXT, `external_tx_url` TEXT, `rate_type` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `from_network` TEXT NOT NULL, `from_token_id` TEXT, `from_raw_amount` TEXT NOT NULL, `from_decimals` INTEGER NOT NULL, `to_network` TEXT NOT NULL, `to_token_id` TEXT, `to_raw_amount` TEXT NOT NULL, `to_decimals` INTEGER NOT NULL, `refund_network` TEXT, `refund_token_id` TEXT, `refund_raw_amount` TEXT, `refund_decimals` INTEGER, `refund_hash` TEXT, PRIMARY KEY(`tx_id`), FOREIGN KEY(`provider_id`) REFERENCES `express_provider`(`id`) ON UPDATE NO ACTION ON DELETE RESTRICT )", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tx_id` TEXT NOT NULL, `owner_address` TEXT NOT NULL, `provider_id` TEXT NOT NULL, `from_address` TEXT NOT NULL, `payin_address` TEXT NOT NULL, `payin_extra_id` TEXT, `payout_address` TEXT NOT NULL, `refund_address` TEXT, `refund_extra_id` TEXT, `rate_type` TEXT NOT NULL, `status` TEXT NOT NULL, `external_tx_id` TEXT, `external_tx_status` TEXT, `external_tx_url` TEXT, `payin_hash` TEXT, `payout_hash` TEXT, `refund_network` TEXT, `refund_contract_address` TEXT, `created_at` TEXT NOT NULL, `pay_till` TEXT, `average_duration` INTEGER, `from_contract_address` TEXT NOT NULL, `from_network` TEXT NOT NULL, `from_decimals` INTEGER NOT NULL, `from_amount` TEXT NOT NULL, `from_actual_amount` TEXT, `to_contract_address` TEXT NOT NULL, `to_network` TEXT NOT NULL, `to_decimals` INTEGER NOT NULL, `to_amount` TEXT NOT NULL, `to_actual_amount` TEXT, PRIMARY KEY(`tx_id`), FOREIGN KEY(`provider_id`) REFERENCES `express_provider`(`id`) ON UPDATE NO ACTION ON DELETE RESTRICT )", "fields": [ { "fieldPath": "txId", @@ -64,6 +64,48 @@ "affinity": "TEXT", "notNull": true }, + { + "fieldPath": "fromAddress", + "columnName": "from_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payinAddress", + "columnName": "payin_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payinExtraId", + "columnName": "payin_extra_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "payoutAddress", + "columnName": "payout_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refundAddress", + "columnName": "refund_address", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refundExtraId", + "columnName": "refund_extra_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rateType", + "columnName": "rate_type", + "affinity": "TEXT", + "notNull": true + }, { "fieldPath": "status", "columnName": "status", @@ -71,11 +113,22 @@ "notNull": true }, { - "fieldPath": "toIsActual", - "columnName": "to_is_actual", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" + "fieldPath": "externalTxId", + "columnName": "external_tx_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "externalTxStatus", + "columnName": "external_tx_status", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "externalTxUrl", + "columnName": "external_tx_url", + "affinity": "TEXT", + "notNull": false }, { "fieldPath": "payinHash", @@ -90,33 +143,39 @@ "notNull": false }, { - "fieldPath": "externalTxId", - "columnName": "external_tx_id", + "fieldPath": "refundNetwork", + "columnName": "refund_network", "affinity": "TEXT", "notNull": false }, { - "fieldPath": "externalTxUrl", - "columnName": "external_tx_url", + "fieldPath": "refundContractAddress", + "columnName": "refund_contract_address", "affinity": "TEXT", "notNull": false }, - { - "fieldPath": "rateType", - "columnName": "rate_type", - "affinity": "TEXT", - "notNull": true - }, { "fieldPath": "createdAt", "columnName": "created_at", - "affinity": "INTEGER", + "affinity": "TEXT", "notNull": true }, { - "fieldPath": "updatedAt", - "columnName": "updated_at", + "fieldPath": "payTill", + "columnName": "pay_till", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "averageDuration", + "columnName": "average_duration", "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "from.contractAddress", + "columnName": "from_contract_address", + "affinity": "TEXT", "notNull": true }, { @@ -125,18 +184,6 @@ "affinity": "TEXT", "notNull": true }, - { - "fieldPath": "from.tokenId", - "columnName": "from_token_id", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "from.rawAmount", - "columnName": "from_raw_amount", - "affinity": "TEXT", - "notNull": true - }, { "fieldPath": "from.decimals", "columnName": "from_decimals", @@ -144,20 +191,26 @@ "notNull": true }, { - "fieldPath": "to.network", - "columnName": "to_network", + "fieldPath": "from.amount", + "columnName": "from_amount", "affinity": "TEXT", "notNull": true }, { - "fieldPath": "to.tokenId", - "columnName": "to_token_id", + "fieldPath": "from.actualAmount", + "columnName": "from_actual_amount", "affinity": "TEXT", "notNull": false }, { - "fieldPath": "to.rawAmount", - "columnName": "to_raw_amount", + "fieldPath": "to.contractAddress", + "columnName": "to_contract_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "to.network", + "columnName": "to_network", "affinity": "TEXT", "notNull": true }, @@ -168,32 +221,14 @@ "notNull": true }, { - "fieldPath": "refund.network", - "columnName": "refund_network", + "fieldPath": "to.amount", + "columnName": "to_amount", "affinity": "TEXT", - "notNull": false + "notNull": true }, { - "fieldPath": "refund.tokenId", - "columnName": "refund_token_id", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "refund.rawAmount", - "columnName": "refund_raw_amount", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "refund.decimals", - "columnName": "refund_decimals", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "refund.hash", - "columnName": "refund_hash", + "fieldPath": "to.actualAmount", + "columnName": "to_actual_amount", "affinity": "TEXT", "notNull": false } @@ -206,15 +241,15 @@ }, "indices": [ { - "name": "index_express_exchange_owner_address_from_network_updated_at", + "name": "index_express_exchange_owner_address_from_network_created_at", "unique": false, "columnNames": [ "owner_address", "from_network", - "updated_at" + "created_at" ], "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_express_exchange_owner_address_from_network_updated_at` ON `${TABLE_NAME}` (`owner_address`, `from_network`, `updated_at`)" + "createSql": "CREATE INDEX IF NOT EXISTS `index_express_exchange_owner_address_from_network_created_at` ON `${TABLE_NAME}` (`owner_address`, `from_network`, `created_at`)" }, { "name": "index_express_exchange_owner_address_payin_hash", @@ -235,16 +270,6 @@ ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_express_exchange_owner_address_payout_hash` ON `${TABLE_NAME}` (`owner_address`, `payout_hash`)" - }, - { - "name": "index_express_exchange_owner_address_refund_hash", - "unique": false, - "columnNames": [ - "owner_address", - "refund_hash" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_express_exchange_owner_address_refund_hash` ON `${TABLE_NAME}` (`owner_address`, `refund_hash`)" } ], "foreignKeys": [ @@ -263,7 +288,7 @@ }, { "tableName": "express_onramp", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tx_id` TEXT NOT NULL, `owner_address` TEXT NOT NULL, `provider_id` TEXT NOT NULL, `status` TEXT NOT NULL, `from_currency_code` TEXT NOT NULL, `from_amount` TEXT NOT NULL, `to_network` TEXT NOT NULL, `to_token_id` TEXT, `to_expected_raw_amount` TEXT NOT NULL, `to_actual_raw_amount` TEXT, `to_decimals` INTEGER NOT NULL, `payout_hash` TEXT, `external_tx_id` TEXT, `external_tx_url` TEXT, `rate_type` TEXT NOT NULL, `fail_reason` TEXT, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `refund_currency_code` TEXT, `refund_amount` TEXT, PRIMARY KEY(`tx_id`), FOREIGN KEY(`provider_id`) REFERENCES `express_provider`(`id`) ON UPDATE NO ACTION ON DELETE RESTRICT )", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tx_id` TEXT NOT NULL, `owner_address` TEXT NOT NULL, `provider_id` TEXT NOT NULL, `from_address` TEXT NOT NULL, `payin_address` TEXT NOT NULL, `payin_extra_id` TEXT, `payout_address` TEXT NOT NULL, `refund_address` TEXT, `refund_extra_id` TEXT, `rate_type` TEXT NOT NULL, `status` TEXT NOT NULL, `external_tx_id` TEXT, `external_tx_status` TEXT, `external_tx_url` TEXT, `payin_hash` TEXT, `payout_hash` TEXT, `refund_network` TEXT, `refund_contract_address` TEXT, `created_at` TEXT NOT NULL, `pay_till` TEXT, `average_duration` INTEGER, `from_contract_address` TEXT NOT NULL, `from_network` TEXT NOT NULL, `from_decimals` INTEGER NOT NULL, `from_amount` TEXT NOT NULL, `from_actual_amount` TEXT, `to_contract_address` TEXT NOT NULL, `to_network` TEXT NOT NULL, `to_decimals` INTEGER NOT NULL, `to_amount` TEXT NOT NULL, `to_actual_amount` TEXT, PRIMARY KEY(`tx_id`), FOREIGN KEY(`provider_id`) REFERENCES `express_provider`(`id`) ON UPDATE NO ACTION ON DELETE RESTRICT )", "fields": [ { "fieldPath": "txId", @@ -284,68 +309,38 @@ "notNull": true }, { - "fieldPath": "status", - "columnName": "status", + "fieldPath": "fromAddress", + "columnName": "from_address", "affinity": "TEXT", "notNull": true }, { - "fieldPath": "fromCurrencyCode", - "columnName": "from_currency_code", + "fieldPath": "payinAddress", + "columnName": "payin_address", "affinity": "TEXT", "notNull": true }, { - "fieldPath": "fromAmount", - "columnName": "from_amount", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "toNetwork", - "columnName": "to_network", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "toTokenId", - "columnName": "to_token_id", + "fieldPath": "payinExtraId", + "columnName": "payin_extra_id", "affinity": "TEXT", "notNull": false }, { - "fieldPath": "toExpectedRawAmount", - "columnName": "to_expected_raw_amount", + "fieldPath": "payoutAddress", + "columnName": "payout_address", "affinity": "TEXT", "notNull": true }, { - "fieldPath": "toActualRawAmount", - "columnName": "to_actual_raw_amount", + "fieldPath": "refundAddress", + "columnName": "refund_address", "affinity": "TEXT", "notNull": false }, { - "fieldPath": "toDecimals", - "columnName": "to_decimals", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "payoutHash", - "columnName": "payout_hash", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "externalTxId", - "columnName": "external_tx_id", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "externalTxUrl", - "columnName": "external_tx_url", + "fieldPath": "refundExtraId", + "columnName": "refund_extra_id", "affinity": "TEXT", "notNull": false }, @@ -356,32 +351,128 @@ "notNull": true }, { - "fieldPath": "failReason", - "columnName": "fail_reason", + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "externalTxId", + "columnName": "external_tx_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "externalTxStatus", + "columnName": "external_tx_status", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "externalTxUrl", + "columnName": "external_tx_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "payinHash", + "columnName": "payin_hash", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "payoutHash", + "columnName": "payout_hash", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refundNetwork", + "columnName": "refund_network", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refundContractAddress", + "columnName": "refund_contract_address", "affinity": "TEXT", "notNull": false }, { "fieldPath": "createdAt", "columnName": "created_at", - "affinity": "INTEGER", + "affinity": "TEXT", "notNull": true }, { - "fieldPath": "updatedAt", - "columnName": "updated_at", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "refund.currencyCode", - "columnName": "refund_currency_code", + "fieldPath": "payTill", + "columnName": "pay_till", "affinity": "TEXT", "notNull": false }, { - "fieldPath": "refund.amount", - "columnName": "refund_amount", + "fieldPath": "averageDuration", + "columnName": "average_duration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "from.contractAddress", + "columnName": "from_contract_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "from.network", + "columnName": "from_network", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "from.decimals", + "columnName": "from_decimals", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "from.amount", + "columnName": "from_amount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "from.actualAmount", + "columnName": "from_actual_amount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "to.contractAddress", + "columnName": "to_contract_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "to.network", + "columnName": "to_network", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "to.decimals", + "columnName": "to_decimals", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "to.amount", + "columnName": "to_amount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "to.actualAmount", + "columnName": "to_actual_amount", "affinity": "TEXT", "notNull": false } @@ -394,15 +485,15 @@ }, "indices": [ { - "name": "index_express_onramp_owner_address_to_network_updated_at", + "name": "index_express_onramp_owner_address_to_network_created_at", "unique": false, "columnNames": [ "owner_address", "to_network", - "updated_at" + "created_at" ], "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_express_onramp_owner_address_to_network_updated_at` ON `${TABLE_NAME}` (`owner_address`, `to_network`, `updated_at`)" + "createSql": "CREATE INDEX IF NOT EXISTS `index_express_onramp_owner_address_to_network_created_at` ON `${TABLE_NAME}` (`owner_address`, `to_network`, `created_at`)" }, { "name": "index_express_onramp_owner_address_payout_hash", @@ -428,12 +519,57 @@ ] } ] + }, + { + "tableName": "express_sync_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`type` TEXT NOT NULL, `address` TEXT NOT NULL, `is_initial_completed` INTEGER NOT NULL, `after_cursor` TEXT, `delta_cursor` TEXT, PRIMARY KEY(`type`, `address`))", + "fields": [ + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isInitialCompleted", + "columnName": "is_initial_completed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "afterCursor", + "columnName": "after_cursor", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "deltaCursor", + "columnName": "delta_cursor", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "type", + "address" + ] + }, + "indices": [], + "foreignKeys": [] } ], "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'aafa8b51b5a5a32d0ec2b0720cec6c1e')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '942246bf975439606ad20e05b930827c')" ] } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/express/TangemExpressApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/express/TangemExpressApi.kt index 6fc2710fcf..eb34209dcd 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/express/TangemExpressApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/express/TangemExpressApi.kt @@ -87,10 +87,17 @@ interface TangemExpressApi { @Body body: ExchangeSentRequestBody, ): ApiResponse - @GET("exchange/history") + @GET("history/exchange") suspend fun getHistory( - @Query("wallet_address") walletAddress: String, - @Query("cursor") cursor: String?, + @Query("fromAddress") fromAddress: String, + @Query("afterCursor") cursor: String?, @Query("limit") limit: Int = 100, ): ApiResponse + + @GET("history/delta/exchange") + suspend fun getHistoryDelta( + @Query("fromAddress") fromAddress: String, + @Query("beforeCursor") cursor: String?, + @Query("limit") limit: Int = 100, + ): ApiResponse } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeHistoryResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeHistoryResponse.kt index 3403faf8c7..cb5024da00 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeHistoryResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeHistoryResponse.kt @@ -5,81 +5,16 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class ExchangeHistoryResponse( - @Json(name = "data") - val data: List, - @Json(name = "next_cursor") - val nextCursor: String, - @Json(name = "has_more") - val hasMore: Boolean, -) { + @Json(name = "items") + val items: List, + @Json(name = "pagination") + val pagination: ExpressPagination, +) - @JsonClass(generateAdapter = true) - data class ExchangeRecord( - @Json(name = "tx_id") - val txId: String, - @Json(name = "status") - val status: String, - @Json(name = "provider") - val provider: Provider, - @Json(name = "from") - val from: AssetRef, - @Json(name = "to") - val to: AssetRef, - @Json(name = "payin_hash") - val payinHash: String?, - @Json(name = "payout_hash") - val payoutHash: String?, - @Json(name = "external_tx_id") - val externalTxId: String?, - @Json(name = "external_tx_url") - val externalTxUrl: String?, - @Json(name = "refund") - val refund: RefundInfo?, - @Json(name = "rate_type") - val rateType: String, - @Json(name = "created_at") - val createdAt: Long, - @Json(name = "updated_at") - val updatedAt: Long, - ) - - @JsonClass(generateAdapter = true) - data class Provider( - @Json(name = "id") - val id: String, - @Json(name = "name") - val name: String, - @Json(name = "icon_url") - val iconUrl: String, - @Json(name = "provider_url") - val providerUrl: String, - ) - - @JsonClass(generateAdapter = true) - data class AssetRef( - @Json(name = "network") - val network: String, - @Json(name = "token_id") - val tokenId: String?, - @Json(name = "raw_amount") - val rawAmount: String, - @Json(name = "decimals") - val decimals: Int, - @Json(name = "is_actual") - val isActual: Boolean?, - ) - - @JsonClass(generateAdapter = true) - data class RefundInfo( - @Json(name = "network") - val network: String, - @Json(name = "token_id") - val tokenId: String?, - @Json(name = "raw_amount") - val rawAmount: String, - @Json(name = "decimals") - val decimals: Int, - @Json(name = "hash") - val hash: String?, - ) -} \ No newline at end of file +@JsonClass(generateAdapter = true) +data class ExchangeHistoryDeltaResponse( + @Json(name = "items") + val items: List, + @Json(name = "pagination") + val pagination: ExpressPaginationDelta, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeItemResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeItemResponse.kt new file mode 100644 index 0000000000..80d86db370 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeItemResponse.kt @@ -0,0 +1,156 @@ +package com.tangem.datasource.api.express.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class ExchangeItemResponse( + // region transaction info + @Json(name = "txId") + val txId: String, + + @Json(name = "providerId") + val providerId: String, + + /** Address from which the source assets were sent */ + @Json(name = "fromAddress") + val fromAddress: String, + + /** Address to which the source assets were transferred for the exchange */ + @Json(name = "payinAddress") + val payinAddress: String, + + /** Extra ID used for the pay-in transaction */ + @Json(name = "payinExtraId") + val payinExtraId: String?, + + /** Address that received the target assets */ + @Json(name = "payoutAddress") + val payoutAddress: String, + + /** Refund destination address */ + @Json(name = "refundAddress") + val refundAddress: String?, + + /** Extra ID used for refunds */ + @Json(name = "refundExtraId") + val refundExtraId: String?, + + /** Exchange rate type (e.g. float, fixed) */ + @Json(name = "rateType") + val rateType: String, + + @Json(name = "status") + val status: Status, + + /** External transaction ID (CEX only) */ + @Json(name = "externalTxId") + val externalTxId: String?, + + /** Transaction status reported by the provider */ + @Json(name = "externalTxStatus") + val externalTxStatus: String?, + + /** URL to view the transaction details (CEX only) */ + @Json(name = "externalTxUrl") + val externalTxUrl: String?, + + /** Blockchain hash of the pay-in transaction */ + @Json(name = "payinHash") + val payinHash: String?, + + /** Blockchain hash of the payout transaction */ + @Json(name = "payoutHash") + val payoutHash: String?, + + /** Network used for the refund transaction */ + @Json(name = "refundNetwork") + val refundNetwork: String?, + + /** Refunded token contract address */ + @Json(name = "refundContractAddress") + val refundContractAddress: String?, + + /** Transaction creation timestamp in ISO-8601 format */ + @Json(name = "createdAt") + val createdAt: String, + + /** Pay-in expiration timestamp in ISO-8601 format */ + @Json(name = "payTill") + val payTill: String?, + + /** Average provider exchange duration in seconds */ + @Json(name = "averageDuration") + val averageDuration: Long?, + // endregion + + // region fromAsset info + @Json(name = "fromContractAddress") + val fromContractAddress: String, + @Json(name = "fromNetwork") + val fromNetwork: String, + @Json(name = "fromDecimals") + val fromDecimals: Int, + @Json(name = "fromAmount") + val fromAmount: String, + // endregion + + // region toAsset info + @Json(name = "toContractAddress") + val toContractAddress: String, + @Json(name = "toNetwork") + val toNetwork: String, + @Json(name = "toDecimals") + val toDecimals: Int, + @Json(name = "toAmount") + val toAmount: String, + @Json(name = "toActualAmount") + val toActualAmount: String?, + // endregion +) { + + enum class Status { + + @Json(name = "unknown") + UNKNOWN, + + @Json(name = "exchange-tx-sent") + EXCHANGE_TX_SENT, + + @Json(name = "waiting") + WAITING, + + @Json(name = "waiting-tx-hash") + WAITING_TX_HASH, + + @Json(name = "expired") + EXPIRED, + + @Json(name = "confirming") + CONFIRMING, + + @Json(name = "exchanging") + EXCHANGING, + + @Json(name = "sending") + SENDING, + + @Json(name = "finished") + FINISHED, + + @Json(name = "failed") + FAILED, + + @Json(name = "tx-failed") + TX_FAILED, + + @Json(name = "refunded") + REFUNDED, + + @Json(name = "verifying") + VERIFYING, + + @Json(name = "paused") + PAUSED, + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExpressPagination.kt b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExpressPagination.kt new file mode 100644 index 0000000000..fa57e12ba6 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExpressPagination.kt @@ -0,0 +1,22 @@ +package com.tangem.datasource.api.express.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class ExpressPagination( + @Json(name = "endCursor") + val endCursor: String?, + @Json(name = "startDeltaCursor") + val startDeltaCursor: String?, + @Json(name = "hasMore") + val hasMore: Boolean, +) + +@JsonClass(generateAdapter = true) +data class ExpressPaginationDelta( + @Json(name = "startCursor") + val startCursor: String?, + @Json(name = "hasMore") + val hasMore: Boolean, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/onramp/OnrampApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/onramp/OnrampApi.kt index c3592a8a9b..ae8edf54ff 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/onramp/OnrampApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/onramp/OnrampApi.kt @@ -3,6 +3,7 @@ package com.tangem.datasource.api.onramp import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.onramp.models.request.OnrampPairsRequest import com.tangem.datasource.api.onramp.models.response.OnrampDataResponse +import com.tangem.datasource.api.onramp.models.response.OnrampHistoryDeltaResponse import com.tangem.datasource.api.onramp.models.response.OnrampHistoryResponse import com.tangem.datasource.api.onramp.models.response.OnrampQuoteResponse import com.tangem.datasource.api.onramp.models.response.OnrampStatusResponse @@ -88,10 +89,17 @@ interface OnrampApi { @Query("txId") txId: String, ): ApiResponse - @GET("onramp/history") + @GET("history/onramp") suspend fun getHistory( - @Query("wallet_address") walletAddress: String, - @Query("cursor") cursor: String?, + @Query("payoutAddress") payoutAddress: String, + @Query("afterCursor") afterCursor: String?, @Query("limit") limit: Int = 100, ): ApiResponse + + @GET("history/delta/onramp") + suspend fun getHistoryDelta( + @Query("payoutAddress") payoutAddress: String, + @Query("beforeCursor") cursor: String?, + @Query("limit") limit: Int = 100, + ): ApiResponse } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/onramp/models/response/OnrampHistoryResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/onramp/models/response/OnrampHistoryResponse.kt index 1b92aeed9c..0e11444277 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/onramp/models/response/OnrampHistoryResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/onramp/models/response/OnrampHistoryResponse.kt @@ -2,86 +2,21 @@ package com.tangem.datasource.api.onramp.models.response import com.squareup.moshi.Json import com.squareup.moshi.JsonClass +import com.tangem.datasource.api.express.models.response.ExpressPagination +import com.tangem.datasource.api.express.models.response.ExpressPaginationDelta @JsonClass(generateAdapter = true) data class OnrampHistoryResponse( - @Json(name = "data") - val data: List, - @Json(name = "next_cursor") - val nextCursor: String, - @Json(name = "has_more") - val hasMore: Boolean, -) { + @Json(name = "items") + val items: List, + @Json(name = "pagination") + val pagination: ExpressPagination, +) - @JsonClass(generateAdapter = true) - data class OnrampRecord( - @Json(name = "tx_id") - val txId: String, - @Json(name = "status") - val status: String, - @Json(name = "provider") - val provider: Provider, - @Json(name = "from") - val from: FiatRef, - @Json(name = "to") - val to: OnrampAssetRef, - @Json(name = "payout_hash") - val payoutHash: String?, - @Json(name = "external_tx_id") - val externalTxId: String?, - @Json(name = "external_tx_url") - val externalTxUrl: String?, - @Json(name = "refund") - val refund: OnrampRefundInfo?, - @Json(name = "rate_type") - val rateType: String, - @Json(name = "fail_reason") - val failReason: String?, - @Json(name = "created_at") - val createdAt: Long, - @Json(name = "updated_at") - val updatedAt: Long, - ) - - @JsonClass(generateAdapter = true) - data class Provider( - @Json(name = "id") - val id: String, - @Json(name = "name") - val name: String, - @Json(name = "icon_url") - val iconUrl: String, - @Json(name = "provider_url") - val providerUrl: String, - ) - - @JsonClass(generateAdapter = true) - data class FiatRef( - @Json(name = "currency_code") - val currencyCode: String, - @Json(name = "amount") - val amount: String, - ) - - @JsonClass(generateAdapter = true) - data class OnrampAssetRef( - @Json(name = "network") - val network: String, - @Json(name = "token_id") - val tokenId: String?, - @Json(name = "expected_raw_amount") - val expectedRawAmount: String, - @Json(name = "actual_raw_amount") - val actualRawAmount: String?, - @Json(name = "decimals") - val decimals: Int, - ) - - @JsonClass(generateAdapter = true) - data class OnrampRefundInfo( - @Json(name = "currency_code") - val currencyCode: String, - @Json(name = "amount") - val amount: String, - ) -} \ No newline at end of file +@JsonClass(generateAdapter = true) +data class OnrampHistoryDeltaResponse( + @Json(name = "items") + val items: List, + @Json(name = "pagination") + val pagination: ExpressPaginationDelta, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/onramp/models/response/OnrampItemResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/onramp/models/response/OnrampItemResponse.kt new file mode 100644 index 0000000000..142191fd97 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/onramp/models/response/OnrampItemResponse.kt @@ -0,0 +1,156 @@ +package com.tangem.datasource.api.onramp.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class OnrampItemResponse( + // region transaction info + @Json(name = "txId") + val txId: String, + + @Json(name = "providerId") + val providerId: String, + + /** Address from which the source assets were taken for the exchange */ + @Json(name = "fromAddress") + val fromAddress: String, + + /** Address to which the assets were transferred for the exchange */ + @Json(name = "payinAddress") + val payinAddress: String, + + /** Extra ID used for the pay-in transaction */ + @Json(name = "payinExtraId") + val payinExtraId: String?, + + /** Address that received the target assets */ + @Json(name = "payoutAddress") + val payoutAddress: String, + + /** Refund destination address */ + @Json(name = "refundAddress") + val refundAddress: String?, + + /** Extra ID used for refunds */ + @Json(name = "refundExtraId") + val refundExtraId: String?, + + /** Exchange rate type used in the transaction (float, fixed) */ + @Json(name = "rateType") + val rateType: String, + + @Json(name = "status") + val status: Status, + + /** External transaction ID (CEX only) */ + @Json(name = "externalTxId") + val externalTxId: String?, + + /** Transaction status reported by the provider */ + @Json(name = "externalTxStatus") + val externalTxStatus: String?, + + /** URL to view the transaction details (CEX only) */ + @Json(name = "externalTxUrl") + val externalTxUrl: String?, + + /** Blockchain hash of the pay-in transaction */ + @Json(name = "payinHash") + val payinHash: String?, + + /** Blockchain hash of the payout transaction */ + @Json(name = "payoutHash") + val payoutHash: String?, + + /** Network used for the refund transaction (when status is refunded) */ + @Json(name = "refundNetwork") + val refundNetwork: String?, + + /** Refunded token contract address */ + @Json(name = "refundContractAddress") + val refundContractAddress: String?, + + /** Transaction creation timestamp in ISO-8601 format */ + @Json(name = "createdAt") + val createdAt: String, + + /** Pay-in expiration timestamp in ISO-8601 format */ + @Json(name = "payTill") + val payTill: String?, + + /** Average provider exchange duration in seconds */ + @Json(name = "averageDuration") + val averageDuration: Long?, + // endregion + + // region fromAsset info + @Json(name = "fromContractAddress") + val fromContractAddress: String, + @Json(name = "fromNetwork") + val fromNetwork: String, + @Json(name = "fromDecimals") + val fromDecimals: Int, + @Json(name = "fromAmount") + val fromAmount: String, + // endregion + + // region toAsset info + @Json(name = "toContractAddress") + val toContractAddress: String, + @Json(name = "toNetwork") + val toNetwork: String, + @Json(name = "toDecimals") + val toDecimals: Int, + @Json(name = "toAmount") + val toAmount: String, + @Json(name = "toActualAmount") + val toActualAmount: String?, + // endregion +) { + + enum class Status { + + @Json(name = "unknown") + UNKNOWN, + + @Json(name = "exchange-tx-sent") + EXCHANGE_TX_SENT, + + @Json(name = "waiting") + WAITING, + + @Json(name = "waiting-tx-hash") + WAITING_TX_HASH, + + @Json(name = "expired") + EXPIRED, + + @Json(name = "confirming") + CONFIRMING, + + @Json(name = "exchanging") + EXCHANGING, + + @Json(name = "sending") + SENDING, + + @Json(name = "finished") + FINISHED, + + @Json(name = "failed") + FAILED, + + @Json(name = "tx-failed") + TX_FAILED, + + @Json(name = "refunded") + REFUNDED, + + @Json(name = "verifying") + VERIFYING, + + @Json(name = "paused") + PAUSED, + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/TxHistoryModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/TxHistoryModule.kt index eb42fb43f2..b3dccfefd5 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/TxHistoryModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/TxHistoryModule.kt @@ -1,23 +1,15 @@ package com.tangem.datasource.di import android.content.Context -import androidx.datastore.core.DataStoreFactory -import androidx.datastore.dataStoreFile import androidx.room.Room import com.tangem.datasource.local.txhistory.db.TxHistoryDatabase -import com.tangem.datasource.local.txhistory.store.CommonSyncState -import com.tangem.datasource.local.txhistory.store.CommonSyncStateKey -import com.tangem.datasource.local.txhistory.store.DefaultTxHistoryStore -import com.tangem.datasource.local.txhistory.store.TxHistoryStore -import com.tangem.datasource.utils.KotlinxDataStoreSerializer -import com.tangem.datasource.utils.KotlinxDataStoreSerializer.Companion.jsonBuilder -import com.tangem.utils.coroutines.AppCoroutineScope +import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao +import com.tangem.datasource.local.txhistory.db.dao.ExpressSyncStateDao import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import kotlinx.serialization.builtins.MapSerializer import javax.inject.Singleton @Module @@ -35,38 +27,15 @@ internal interface TxHistoryModule { context = context, klass = TxHistoryDatabase::class.java, name = TX_HISTORY_DATABASE_NAME, - ).build() + ) + .fallbackToDestructiveMigration(true) + .build() } @Provides - @Singleton - fun provideTxHistoryStore(@ApplicationContext context: Context, appScope: AppCoroutineScope): TxHistoryStore { - val commonSerializer = KotlinxDataStoreSerializer( - defaultValue = emptyMap(), - serializer = MapSerializer( - CommonSyncStateKey.serializer(), - CommonSyncState.serializer(), - ), - json = jsonBuilder { - allowStructuredMapKeys = true - }, - ) + fun provideExpressHistoryDao(database: TxHistoryDatabase): ExpressHistoryDao = database.expressHistoryDao() - val expressExchangeStore = DataStoreFactory.create( - serializer = commonSerializer, - produceFile = { context.dataStoreFile(fileName = "TxHistoryExpressExchangeStore") }, - scope = appScope, - ) - val expressOnrampStore = DataStoreFactory.create( - serializer = commonSerializer, - produceFile = { context.dataStoreFile(fileName = "TxHistoryExpressOnrampStore") }, - scope = appScope, - ) - - return DefaultTxHistoryStore( - expressExchangeStore = expressExchangeStore, - expressOnrampStore = expressOnrampStore, - ) - } + @Provides + fun provideSyncStateDao(database: TxHistoryDatabase): ExpressSyncStateDao = database.syncStateDao() } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/TxHistoryDatabase.kt b/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/TxHistoryDatabase.kt index 6cf81922b3..549e980d64 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/TxHistoryDatabase.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/TxHistoryDatabase.kt @@ -2,7 +2,9 @@ package com.tangem.datasource.local.txhistory.db import androidx.room.Database import androidx.room.RoomDatabase -import com.tangem.datasource.local.txhistory.db.entity.ExpressHistoryDao +import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao +import com.tangem.datasource.local.txhistory.db.dao.ExpressSyncStateDao +import com.tangem.datasource.local.txhistory.db.entity.express.ExpressSyncStateEntity import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity import com.tangem.datasource.local.txhistory.db.entity.express.ExpressProviderEntity @@ -13,9 +15,12 @@ import com.tangem.datasource.local.txhistory.db.entity.express.ExpressProviderEn ExpressProviderEntity::class, ExpressExchangeEntity::class, ExpressOnrampEntity::class, + ExpressSyncStateEntity::class, ], ) abstract class TxHistoryDatabase : RoomDatabase() { abstract fun expressHistoryDao(): ExpressHistoryDao + + abstract fun syncStateDao(): ExpressSyncStateDao } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/entity/ExpressHistoryDao.kt b/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/dao/ExpressHistoryDao.kt similarity index 83% rename from core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/entity/ExpressHistoryDao.kt rename to core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/dao/ExpressHistoryDao.kt index 4d38379239..c5185f4cd6 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/entity/ExpressHistoryDao.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/dao/ExpressHistoryDao.kt @@ -1,4 +1,4 @@ -package com.tangem.datasource.local.txhistory.db.entity +package com.tangem.datasource.local.txhistory.db.dao import androidx.room.Dao import androidx.room.Insert @@ -26,7 +26,7 @@ interface ExpressHistoryDao { SELECT * FROM express_exchange WHERE owner_address = :ownerAddress - ORDER BY updated_at DESC + ORDER BY created_at DESC """, ) fun observeExchanges(ownerAddress: String): Flow> @@ -36,7 +36,7 @@ interface ExpressHistoryDao { SELECT * FROM express_onramp WHERE owner_address = :ownerAddress - ORDER BY updated_at DESC + ORDER BY created_at DESC """, ) fun observeOnramps(ownerAddress: String): Flow> @@ -63,17 +63,6 @@ interface ExpressHistoryDao { ) suspend fun findExchangeByPayoutHash(ownerAddress: String, hash: String): ExpressExchangeEntity? - @Query( - """ - SELECT * - FROM express_exchange - WHERE owner_address = :ownerAddress - AND refund_hash = :hash - LIMIT 1 - """, - ) - suspend fun findExchangeByRefundHash(ownerAddress: String, hash: String): ExpressExchangeEntity? - @Query( """ SELECT * diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/dao/ExpressSyncStateDao.kt b/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/dao/ExpressSyncStateDao.kt new file mode 100644 index 0000000000..8373df85f8 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/dao/ExpressSyncStateDao.kt @@ -0,0 +1,26 @@ +package com.tangem.datasource.local.txhistory.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.tangem.datasource.local.txhistory.db.entity.express.ExpressSyncStateEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface ExpressSyncStateDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(item: ExpressSyncStateEntity) + + @Query( + """ + SELECT * + FROM express_sync_state + WHERE type = :type + AND address = :address + LIMIT 1 + """, + ) + fun observe(type: String, address: String): Flow +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/entity/express/ExpressExchangeEntity.kt b/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/entity/express/ExpressExchangeEntity.kt index d0d7cc7c87..04dd284ddc 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/entity/express/ExpressExchangeEntity.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/entity/express/ExpressExchangeEntity.kt @@ -2,7 +2,11 @@ package com.tangem.datasource.local.txhistory.db.entity.express import androidx.room.* -@Suppress("BooleanPropertyNaming") +/** + * Persisted representation of a single exchange transaction. + * + * Mirrors [com.tangem.datasource.api.express.models.response.ExchangeItemResponse]. + */ @Entity( tableName = "express_exchange", foreignKeys = [ @@ -14,10 +18,9 @@ import androidx.room.* ), ], indices = [ - Index(value = ["owner_address", "from_network", "updated_at"]), + Index(value = ["owner_address", "from_network", "created_at"]), Index(value = ["owner_address", "payin_hash"]), Index(value = ["owner_address", "payout_hash"]), - Index(value = ["owner_address", "refund_hash"]), ], ) data class ExpressExchangeEntity( @@ -26,55 +29,38 @@ data class ExpressExchangeEntity( @ColumnInfo(name = "tx_id") val txId: String, + /** + * Address used to query the history. For exchange it matches [fromAddress]. + */ @ColumnInfo(name = "owner_address") val ownerAddress: String, @ColumnInfo(name = "provider_id") val providerId: String, - /** - * waiting - * confirming - * exchanging - * sending - * finished - * failed - * refunded - * expired - */ - @ColumnInfo(name = "status") - val status: String, + /** Address from which the source assets were sent */ + @ColumnInfo(name = "from_address") + val fromAddress: String, - @Embedded(prefix = "from_") - val from: AssetEmbedded, + /** Address to which the source assets were transferred for the exchange */ + @ColumnInfo(name = "payin_address") + val payinAddress: String, - @Embedded(prefix = "to_") - val to: AssetEmbedded, + /** Extra ID used for the pay-in transaction */ + @ColumnInfo(name = "payin_extra_id") + val payinExtraId: String?, - /** - * true -> actual provider-confirmed amount - * false -> estimated amount - */ - @ColumnInfo(name = "to_is_actual", defaultValue = "0") - val toIsActual: Boolean, + /** Address that received the target assets */ + @ColumnInfo(name = "payout_address") + val payoutAddress: String, - /** - * Match key for PAYIN leg - */ - @ColumnInfo(name = "payin_hash") - val payinHash: String?, + /** Refund destination address */ + @ColumnInfo(name = "refund_address") + val refundAddress: String?, - /** - * Match key for PAYOUT leg - */ - @ColumnInfo(name = "payout_hash") - val payoutHash: String?, - - @ColumnInfo(name = "external_tx_id") - val externalTxId: String?, - - @ColumnInfo(name = "external_tx_url") - val externalTxUrl: String?, + /** Extra ID used for refunds */ + @ColumnInfo(name = "refund_extra_id") + val refundExtraId: String?, /** * fixed / float @@ -82,49 +68,88 @@ data class ExpressExchangeEntity( @ColumnInfo(name = "rate_type") val rateType: String, + /** + * unknown + * exchange-tx-sent + * waiting + * waiting-tx-hash + * expired + * confirming + * exchanging + * sending + * finished + * failed + * tx-failed + * refunded + * verifying + * paused + */ + @ColumnInfo(name = "status") + val status: String, + + /** External transaction ID (CEX only) */ + @ColumnInfo(name = "external_tx_id") + val externalTxId: String?, + + /** Transaction status reported by the provider */ + @ColumnInfo(name = "external_tx_status") + val externalTxStatus: String?, + + /** URL to view the transaction details (CEX only) */ + @ColumnInfo(name = "external_tx_url") + val externalTxUrl: String?, + + /** Blockchain hash of the pay-in transaction */ + @ColumnInfo(name = "payin_hash") + val payinHash: String?, + + /** Blockchain hash of the payout transaction */ + @ColumnInfo(name = "payout_hash") + val payoutHash: String?, + + /** Network used for the refund transaction */ + @ColumnInfo(name = "refund_network") + val refundNetwork: String?, + + /** Refunded token contract address */ + @ColumnInfo(name = "refund_contract_address") + val refundContractAddress: String?, + + /** Transaction creation timestamp in ISO-8601 format */ @ColumnInfo(name = "created_at") - val createdAt: Long, + val createdAt: String, - @ColumnInfo(name = "updated_at") - val updatedAt: Long, + /** Pay-in expiration timestamp in ISO-8601 format */ + @ColumnInfo(name = "pay_till") + val payTill: String?, - @Embedded(prefix = "refund_") - val refund: RefundEmbedded?, + /** Average provider exchange duration in seconds */ + @ColumnInfo(name = "average_duration") + val averageDuration: Long?, + + @Embedded(prefix = "from_") + val from: AssetEmbedded, + + @Embedded(prefix = "to_") + val to: AssetEmbedded, ) { data class AssetEmbedded( + @ColumnInfo(name = "contract_address") + val contractAddress: String, + @ColumnInfo(name = "network") val network: String, - @ColumnInfo(name = "token_id") - val tokenId: String?, - - @ColumnInfo(name = "raw_amount") - val rawAmount: String, - @ColumnInfo(name = "decimals") val decimals: Int, - ) - data class RefundEmbedded( + @ColumnInfo(name = "amount") + val amount: String, - @ColumnInfo(name = "network") - val network: String?, - - @ColumnInfo(name = "token_id") - val tokenId: String?, - - @ColumnInfo(name = "raw_amount") - val rawAmount: String?, - - @ColumnInfo(name = "decimals") - val decimals: Int?, - - /** - * Match key for REFUND leg - */ - @ColumnInfo(name = "hash") - val hash: String?, + /** Actual provider-confirmed amount. Present only for the [ExpressExchangeEntity.to] asset */ + @ColumnInfo(name = "actual_amount") + val actualAmount: String?, ) } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/entity/express/ExpressOnrampEntity.kt b/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/entity/express/ExpressOnrampEntity.kt index 3c416ff881..838a41842b 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/entity/express/ExpressOnrampEntity.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/entity/express/ExpressOnrampEntity.kt @@ -2,6 +2,11 @@ package com.tangem.datasource.local.txhistory.db.entity.express import androidx.room.* +/** + * Persisted representation of a single onramp transaction. + * + * Mirrors [com.tangem.datasource.api.onramp.models.response.OnrampItemResponse]. + */ @Entity( tableName = "express_onramp", foreignKeys = [ @@ -13,7 +18,7 @@ import androidx.room.* ), ], indices = [ - Index(value = ["owner_address", "to_network", "updated_at"]), + Index(value = ["owner_address", "to_network", "created_at"]), Index(value = ["owner_address", "payout_hash"]), ], ) @@ -23,71 +28,38 @@ data class ExpressOnrampEntity( @ColumnInfo(name = "tx_id") val txId: String, + /** + * Address used to query the history. For onramp it matches [payoutAddress]. + */ @ColumnInfo(name = "owner_address") val ownerAddress: String, @ColumnInfo(name = "provider_id") val providerId: String, - /** + /** Address from which the source assets were taken for the exchange */ + @ColumnInfo(name = "from_address") + val fromAddress: String, - * waiting-for-payment - * payment-processing - * paused - * verifying - * sending - * finished - * failed - * expired - * refunded - */ - @ColumnInfo(name = "status") - val status: String, + /** Address to which the assets were transferred for the exchange */ + @ColumnInfo(name = "payin_address") + val payinAddress: String, - /** - * ISO-4217 - */ - @ColumnInfo(name = "from_currency_code") - val fromCurrencyCode: String, + /** Extra ID used for the pay-in transaction */ + @ColumnInfo(name = "payin_extra_id") + val payinExtraId: String?, - /** - * Decimal string - */ - @ColumnInfo(name = "from_amount") - val fromAmount: String, + /** Address that received the target assets */ + @ColumnInfo(name = "payout_address") + val payoutAddress: String, - @ColumnInfo(name = "to_network") - val toNetwork: String, + /** Refund destination address */ + @ColumnInfo(name = "refund_address") + val refundAddress: String?, - @ColumnInfo(name = "to_token_id") - val toTokenId: String?, - - /** - * Estimated amount at creation moment - */ - @ColumnInfo(name = "to_expected_raw_amount") - val toExpectedRawAmount: String, - - /** - * Actual provider-confirmed amount - */ - @ColumnInfo(name = "to_actual_raw_amount") - val toActualRawAmount: String?, - - @ColumnInfo(name = "to_decimals") - val toDecimals: Int, - - /** - * Match key with gateway_tx.hash - */ - @ColumnInfo(name = "payout_hash") - val payoutHash: String?, - - @ColumnInfo(name = "external_tx_id") - val externalTxId: String?, - - @ColumnInfo(name = "external_tx_url") - val externalTxUrl: String?, + /** Extra ID used for refunds */ + @ColumnInfo(name = "refund_extra_id") + val refundExtraId: String?, /** * fixed / float @@ -95,28 +67,88 @@ data class ExpressOnrampEntity( @ColumnInfo(name = "rate_type") val rateType: String, - @ColumnInfo(name = "fail_reason") - val failReason: String?, + /** + * unknown + * exchange-tx-sent + * waiting + * waiting-tx-hash + * expired + * confirming + * exchanging + * sending + * finished + * failed + * tx-failed + * refunded + * verifying + * paused + */ + @ColumnInfo(name = "status") + val status: String, + /** External transaction ID (CEX only) */ + @ColumnInfo(name = "external_tx_id") + val externalTxId: String?, + + /** Transaction status reported by the provider */ + @ColumnInfo(name = "external_tx_status") + val externalTxStatus: String?, + + /** URL to view the transaction details (CEX only) */ + @ColumnInfo(name = "external_tx_url") + val externalTxUrl: String?, + + /** Blockchain hash of the pay-in transaction */ + @ColumnInfo(name = "payin_hash") + val payinHash: String?, + + /** Blockchain hash of the payout transaction */ + @ColumnInfo(name = "payout_hash") + val payoutHash: String?, + + /** Network used for the refund transaction (when status is refunded) */ + @ColumnInfo(name = "refund_network") + val refundNetwork: String?, + + /** Refunded token contract address */ + @ColumnInfo(name = "refund_contract_address") + val refundContractAddress: String?, + + /** Transaction creation timestamp in ISO-8601 format */ @ColumnInfo(name = "created_at") - val createdAt: Long, + val createdAt: String, - @ColumnInfo(name = "updated_at") - val updatedAt: Long, + /** Pay-in expiration timestamp in ISO-8601 format */ + @ColumnInfo(name = "pay_till") + val payTill: String?, - @Embedded(prefix = "refund_") - val refund: RefundEmbedded?, + /** Average provider exchange duration in seconds */ + @ColumnInfo(name = "average_duration") + val averageDuration: Long?, + + @Embedded(prefix = "from_") + val from: AssetEmbedded, + + @Embedded(prefix = "to_") + val to: AssetEmbedded, ) { - data class RefundEmbedded( + data class AssetEmbedded( - /** - * ISO-4217 - */ - @ColumnInfo(name = "currency_code") - val currencyCode: String?, + @ColumnInfo(name = "contract_address") + val contractAddress: String, + + @ColumnInfo(name = "network") + val network: String, + + @ColumnInfo(name = "decimals") + val decimals: Int, @ColumnInfo(name = "amount") - val amount: String?, + val amount: String, + + /** Actual provider-confirmed amount. Present only for the [ExpressOnrampEntity.to] asset */ + @ColumnInfo(name = "actual_amount") + val actualAmount: String?, ) } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/entity/express/ExpressSyncStateEntity.kt b/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/entity/express/ExpressSyncStateEntity.kt new file mode 100644 index 0000000000..bd6bb85913 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/entity/express/ExpressSyncStateEntity.kt @@ -0,0 +1,39 @@ +package com.tangem.datasource.local.txhistory.db.entity.express + +import androidx.room.ColumnInfo +import androidx.room.Entity + +/** + * Persisted sync state of the express tx history. + * + * Stored inside [com.tangem.datasource.local.txhistory.db.TxHistoryDatabase] on purpose: if the history tables are + * dropped (e.g. destructive migration), the sync state is wiped together with them and the history is re-synced + * from scratch. + */ +@Entity( + tableName = "express_sync_state", + primaryKeys = ["type", "address"], +) +data class ExpressSyncStateEntity( + + @ColumnInfo(name = "type") + val type: String, + + @ColumnInfo(name = "address") + val address: String, + + @ColumnInfo(name = "is_initial_completed") + val isInitialCompleted: Boolean, + + @ColumnInfo(name = "after_cursor") + val afterCursor: String?, + + @ColumnInfo(name = "delta_cursor") + val deltaCursor: String?, +) { + + enum class Type { + EXCHANGE, + ONRAMP, + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/store/DefaultTxHistoryStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/store/DefaultTxHistoryStore.kt deleted file mode 100644 index e444e0b7a2..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/store/DefaultTxHistoryStore.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.tangem.datasource.local.txhistory.store - -import androidx.datastore.core.DataStore -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map - -internal class DefaultTxHistoryStore( - private val expressExchangeStore: DataStore>, - private val expressOnrampStore: DataStore>, -) : TxHistoryStore { - - override fun expressExchangeSyncState(key: CommonSyncStateKey): Flow { - return expressExchangeStore.data.map { map -> map.getOrDefault(key) } - } - - override fun expressOnrampSyncState(key: CommonSyncStateKey): Flow { - return expressOnrampStore.data.map { map -> map.getOrDefault(key) } - } - - override suspend fun updateExpressExchangeSyncState( - key: CommonSyncStateKey, - value: CommonSyncState, - ): CommonSyncState { - return expressExchangeStore.updateData { map -> map.plus(key to value) } - .getOrDefault(key) - } - - override suspend fun updateExpressOnrampSyncState( - key: CommonSyncStateKey, - value: CommonSyncState, - ): CommonSyncState { - return expressOnrampStore.updateData { map -> map.plus(key to value) } - .getOrDefault(key) - } - - private fun Map.getOrDefault(key: CommonSyncStateKey): CommonSyncState = - this.getOrDefault(key, CommonSyncState.default(key)) -} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/store/SyncStateModel.kt b/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/store/SyncStateModel.kt deleted file mode 100644 index 38e571202a..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/store/SyncStateModel.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.tangem.datasource.local.txhistory.store - -import com.tangem.domain.models.account.AccountId -import kotlinx.serialization.Serializable - -@Serializable -data class CommonSyncStateKey( - val accountId: AccountId, - val address: String, -) - -@Serializable -data class CommonSyncState( - val accountId: AccountId, - val address: String, - val isInitialCompleted: Boolean, - val cursor: String?, -) { - companion object { - fun default(key: CommonSyncStateKey) = CommonSyncState( - accountId = key.accountId, - address = key.address, - isInitialCompleted = false, - cursor = null, - ) - } -} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/store/TxHistoryStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/store/TxHistoryStore.kt deleted file mode 100644 index 08f7b32a26..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/store/TxHistoryStore.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.tangem.datasource.local.txhistory.store - -import kotlinx.coroutines.flow.Flow - -interface TxHistoryStore { - - fun expressExchangeSyncState(key: CommonSyncStateKey): Flow - fun expressOnrampSyncState(key: CommonSyncStateKey): Flow - - suspend fun updateExpressExchangeSyncState(key: CommonSyncStateKey, value: CommonSyncState): CommonSyncState - suspend fun updateExpressOnrampSyncState(key: CommonSyncStateKey, value: CommonSyncState): CommonSyncState -} \ No newline at end of file diff --git a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/ExpressHistoryRepository.kt b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/ExpressHistoryRepository.kt new file mode 100644 index 0000000000..055e76b74c --- /dev/null +++ b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/ExpressHistoryRepository.kt @@ -0,0 +1,103 @@ +package com.tangem.data.txhistory.repository + +import com.tangem.data.txhistory.repository.converter.toEntity +import com.tangem.datasource.api.common.response.getOrThrow +import com.tangem.datasource.api.express.TangemExpressApi +import com.tangem.datasource.api.express.models.response.ExchangeHistoryDeltaResponse +import com.tangem.datasource.api.express.models.response.ExchangeHistoryResponse +import com.tangem.datasource.api.express.models.response.ExchangeItemResponse +import com.tangem.datasource.api.onramp.OnrampApi +import com.tangem.datasource.api.onramp.models.response.OnrampHistoryDeltaResponse +import com.tangem.datasource.api.onramp.models.response.OnrampHistoryResponse +import com.tangem.datasource.api.onramp.models.response.OnrampItemResponse +import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao +import com.tangem.datasource.local.txhistory.db.dao.ExpressSyncStateDao +import com.tangem.datasource.local.txhistory.db.entity.express.ExpressSyncStateEntity +import kotlinx.coroutines.flow.first +import javax.inject.Inject + +/** + * Fetches express (exchange & onramp) transaction history from the API and persists it into the local database. + * + */ +internal class ExpressHistoryRepository @Inject constructor( + private val exchangeApi: TangemExpressApi, + private val onrampApi: OnrampApi, + private val expressHistoryDao: ExpressHistoryDao, + private val expressSyncStateDao: ExpressSyncStateDao, +) { + + suspend fun fetchExchangeHistory(fromAddress: String, limit: Int = DEFAULT_LIMIT): ExchangeHistoryResponse { + val state = syncState(ExpressSyncStateEntity.Type.EXCHANGE, fromAddress) + + val response = exchangeApi.getHistory( + fromAddress = fromAddress, + cursor = state?.afterCursor, + limit = limit, + ).getOrThrow() + + saveExchanges(ownerAddress = fromAddress, items = response.items) + return response + } + + suspend fun fetchExchangeHistoryDelta( + fromAddress: String, + limit: Int = DEFAULT_LIMIT, + ): ExchangeHistoryDeltaResponse { + val state = syncState(ExpressSyncStateEntity.Type.EXCHANGE, fromAddress) + + val response = exchangeApi.getHistoryDelta( + fromAddress = fromAddress, + cursor = state?.deltaCursor, + limit = limit, + ).getOrThrow() + + saveExchanges(ownerAddress = fromAddress, items = response.items) + return response + } + + suspend fun fetchOnrampHistory(payoutAddress: String, limit: Int = DEFAULT_LIMIT): OnrampHistoryResponse { + val state = syncState(ExpressSyncStateEntity.Type.ONRAMP, payoutAddress) + + val response = onrampApi.getHistory( + payoutAddress = payoutAddress, + afterCursor = state?.afterCursor, + limit = limit, + ).getOrThrow() + + saveOnramps(ownerAddress = payoutAddress, items = response.items) + return response + } + + suspend fun fetchOnrampHistoryDelta( + payoutAddress: String, + limit: Int = DEFAULT_LIMIT, + ): OnrampHistoryDeltaResponse { + val state = syncState(ExpressSyncStateEntity.Type.ONRAMP, payoutAddress) + + val response = onrampApi.getHistoryDelta( + payoutAddress = payoutAddress, + cursor = state?.deltaCursor, + limit = limit, + ).getOrThrow() + + saveOnramps(ownerAddress = payoutAddress, items = response.items) + return response + } + + suspend fun syncState(type: ExpressSyncStateEntity.Type, address: String): ExpressSyncStateEntity? { + return expressSyncStateDao.observe(type = type.name, address = address).first() + } + + private suspend fun saveExchanges(ownerAddress: String, items: List) { + expressHistoryDao.upsertExchanges(items.map { it.toEntity(ownerAddress) }) + } + + private suspend fun saveOnramps(ownerAddress: String, items: List) { + expressHistoryDao.upsertOnramps(items.map { it.toEntity(ownerAddress) }) + } + + private companion object { + const val DEFAULT_LIMIT = 100 + } +} \ No newline at end of file diff --git a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/converter/ExpressHistoryConverter.kt b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/converter/ExpressHistoryConverter.kt new file mode 100644 index 0000000000..de66d7e911 --- /dev/null +++ b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/converter/ExpressHistoryConverter.kt @@ -0,0 +1,91 @@ +package com.tangem.data.txhistory.repository.converter + +import com.tangem.datasource.api.express.models.response.ExchangeItemResponse +import com.tangem.datasource.api.onramp.models.response.OnrampItemResponse +import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity +import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity + +/** + * Maps API history items into their persisted [androidx.room.Entity] representations. + * + * @param ownerAddress address the history was requested for. Stored as the query key. + */ +internal fun ExchangeItemResponse.toEntity(ownerAddress: String): ExpressExchangeEntity { + return ExpressExchangeEntity( + txId = txId, + ownerAddress = ownerAddress, + providerId = providerId, + fromAddress = fromAddress, + payinAddress = payinAddress, + payinExtraId = payinExtraId, + payoutAddress = payoutAddress, + refundAddress = refundAddress, + refundExtraId = refundExtraId, + rateType = rateType, + status = status.name, + externalTxId = externalTxId, + externalTxStatus = externalTxStatus, + externalTxUrl = externalTxUrl, + payinHash = payinHash, + payoutHash = payoutHash, + refundNetwork = refundNetwork, + refundContractAddress = refundContractAddress, + createdAt = createdAt, + payTill = payTill, + averageDuration = averageDuration, + from = ExpressExchangeEntity.AssetEmbedded( + contractAddress = fromContractAddress, + network = fromNetwork, + decimals = fromDecimals, + amount = fromAmount, + actualAmount = null, + ), + to = ExpressExchangeEntity.AssetEmbedded( + contractAddress = toContractAddress, + network = toNetwork, + decimals = toDecimals, + amount = toAmount, + actualAmount = toActualAmount, + ), + ) +} + +internal fun OnrampItemResponse.toEntity(ownerAddress: String): ExpressOnrampEntity { + return ExpressOnrampEntity( + txId = txId, + ownerAddress = ownerAddress, + providerId = providerId, + fromAddress = fromAddress, + payinAddress = payinAddress, + payinExtraId = payinExtraId, + payoutAddress = payoutAddress, + refundAddress = refundAddress, + refundExtraId = refundExtraId, + rateType = rateType, + status = status.name, + externalTxId = externalTxId, + externalTxStatus = externalTxStatus, + externalTxUrl = externalTxUrl, + payinHash = payinHash, + payoutHash = payoutHash, + refundNetwork = refundNetwork, + refundContractAddress = refundContractAddress, + createdAt = createdAt, + payTill = payTill, + averageDuration = averageDuration, + from = ExpressOnrampEntity.AssetEmbedded( + contractAddress = fromContractAddress, + network = fromNetwork, + decimals = fromDecimals, + amount = fromAmount, + actualAmount = null, + ), + to = ExpressOnrampEntity.AssetEmbedded( + contractAddress = toContractAddress, + network = toNetwork, + decimals = toDecimals, + amount = toAmount, + actualAmount = toActualAmount, + ), + ) +} \ No newline at end of file diff --git a/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/fetcher/DefaultAccountTxHistoryFetcherTest.kt b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/fetcher/DefaultAccountTxHistoryFetcherTest.kt index 5b8a4953db..d42cc517a3 100644 --- a/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/fetcher/DefaultAccountTxHistoryFetcherTest.kt +++ b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/fetcher/DefaultAccountTxHistoryFetcherTest.kt @@ -1,7 +1,7 @@ package com.tangem.data.txhistory.fetcher import com.google.common.truth.Truth.assertThat -import com.tangem.common.test.TestAppCoroutineScope +import com.tangem.test.core.TestAppCoroutineScope import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountId diff --git a/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/fetcher/DefaultAppTxHistoryFetcherTest.kt b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/fetcher/DefaultAppTxHistoryFetcherTest.kt index 8176ea0589..8b69c2d793 100644 --- a/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/fetcher/DefaultAppTxHistoryFetcherTest.kt +++ b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/fetcher/DefaultAppTxHistoryFetcherTest.kt @@ -1,7 +1,7 @@ package com.tangem.data.txhistory.fetcher import com.google.common.truth.Truth.assertThat -import com.tangem.common.test.TestAppCoroutineScope +import com.tangem.test.core.TestAppCoroutineScope import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet diff --git a/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/fetcher/DefaultWalletTxHistoryFetcherTest.kt b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/fetcher/DefaultWalletTxHistoryFetcherTest.kt index 279714b077..5afd604222 100644 --- a/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/fetcher/DefaultWalletTxHistoryFetcherTest.kt +++ b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/fetcher/DefaultWalletTxHistoryFetcherTest.kt @@ -1,7 +1,7 @@ package com.tangem.data.txhistory.fetcher import com.google.common.truth.Truth.assertThat -import com.tangem.common.test.TestAppCoroutineScope +import com.tangem.test.core.TestAppCoroutineScope import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory import com.tangem.domain.account.models.AccountList import com.tangem.domain.account.supplier.SingleAccountListSupplier diff --git a/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/ExpressHistoryRepositoryTest.kt b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/ExpressHistoryRepositoryTest.kt new file mode 100644 index 0000000000..99b8f8019a --- /dev/null +++ b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/ExpressHistoryRepositoryTest.kt @@ -0,0 +1,367 @@ +package com.tangem.data.txhistory.repository + +import com.google.common.truth.Truth.assertThat +import com.tangem.data.txhistory.repository.converter.toEntity +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.datasource.api.express.TangemExpressApi +import com.tangem.datasource.api.express.models.response.ExchangeHistoryDeltaResponse +import com.tangem.datasource.api.express.models.response.ExchangeHistoryResponse +import com.tangem.datasource.api.express.models.response.ExchangeItemResponse +import com.tangem.datasource.api.express.models.response.ExpressPagination +import com.tangem.datasource.api.express.models.response.ExpressPaginationDelta +import com.tangem.datasource.api.onramp.OnrampApi +import com.tangem.datasource.api.onramp.models.response.OnrampHistoryDeltaResponse +import com.tangem.datasource.api.onramp.models.response.OnrampHistoryResponse +import com.tangem.datasource.api.onramp.models.response.OnrampItemResponse +import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao +import com.tangem.datasource.local.txhistory.db.dao.ExpressSyncStateDao +import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity +import com.tangem.datasource.local.txhistory.db.entity.express.ExpressSyncStateEntity +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class ExpressHistoryRepositoryTest { + + private val exchangeApi: TangemExpressApi = mockk() + private val onrampApi: OnrampApi = mockk() + private val expressHistoryDao: ExpressHistoryDao = mockk(relaxUnitFun = true) + private val expressSyncStateDao: ExpressSyncStateDao = mockk() + + private val repository = ExpressHistoryRepository( + exchangeApi = exchangeApi, + onrampApi = onrampApi, + expressHistoryDao = expressHistoryDao, + expressSyncStateDao = expressSyncStateDao, + ) + + @BeforeEach + fun setup() { + clearMocks(exchangeApi, onrampApi, expressHistoryDao, expressSyncStateDao) + } + + // region exchange history + + @Test + fun `GIVEN sync state WHEN fetchExchangeHistory THEN passes after cursor and persists items`() = runTest { + // GIVEN + val item = createExchangeItem() + val response = ExchangeHistoryResponse(items = listOf(item), pagination = pagination()) + stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, syncState(afterCursor = AFTER_CURSOR)) + coEvery { + exchangeApi.getHistory(fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = any()) + } returns ApiResponse.Success(response) + + // WHEN + val result = repository.fetchExchangeHistory(fromAddress = ADDRESS) + + // THEN + assertThat(result).isEqualTo(response) + coVerify(exactly = 1) { + exchangeApi.getHistory(fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = DEFAULT_LIMIT) + } + coVerify(exactly = 1) { expressHistoryDao.upsertExchanges(listOf(item.toEntity(ADDRESS))) } + } + + @Test + fun `GIVEN no sync state WHEN fetchExchangeHistory THEN passes null cursor`() = runTest { + // GIVEN + val response = ExchangeHistoryResponse(items = emptyList(), pagination = pagination()) + stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, state = null) + coEvery { + exchangeApi.getHistory(fromAddress = ADDRESS, cursor = null, limit = any()) + } returns ApiResponse.Success(response) + + // WHEN + repository.fetchExchangeHistory(fromAddress = ADDRESS) + + // THEN + coVerify(exactly = 1) { + exchangeApi.getHistory(fromAddress = ADDRESS, cursor = null, limit = DEFAULT_LIMIT) + } + } + + @Test + fun `GIVEN custom limit WHEN fetchExchangeHistory THEN forwards limit to api`() = runTest { + // GIVEN + val response = ExchangeHistoryResponse(items = emptyList(), pagination = pagination()) + stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, syncState(afterCursor = AFTER_CURSOR)) + coEvery { + exchangeApi.getHistory(fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = any()) + } returns ApiResponse.Success(response) + + // WHEN + repository.fetchExchangeHistory(fromAddress = ADDRESS, limit = 25) + + // THEN + coVerify(exactly = 1) { + exchangeApi.getHistory(fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = 25) + } + } + + @Test + fun `GIVEN api error WHEN fetchExchangeHistory THEN throws and does not persist`() = runTest { + // GIVEN + stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, syncState(afterCursor = AFTER_CURSOR)) + val error = httpError() + coEvery { + exchangeApi.getHistory(fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = any()) + } returns ApiResponse.Error(error).cast() + + // WHEN + val thrown = runCatching { repository.fetchExchangeHistory(fromAddress = ADDRESS) }.exceptionOrNull() + + // THEN + assertThat(thrown).isEqualTo(error) + coVerify(exactly = 0) { expressHistoryDao.upsertExchanges(any()) } + } + + @Test + fun `GIVEN sync state WHEN fetchExchangeHistoryDelta THEN passes delta cursor and persists items`() = runTest { + // GIVEN + val item = createExchangeItem() + val response = ExchangeHistoryDeltaResponse(items = listOf(item), pagination = paginationDelta()) + stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, syncState(deltaCursor = DELTA_CURSOR)) + coEvery { + exchangeApi.getHistoryDelta(fromAddress = ADDRESS, cursor = DELTA_CURSOR, limit = any()) + } returns ApiResponse.Success(response) + + // WHEN + val result = repository.fetchExchangeHistoryDelta(fromAddress = ADDRESS) + + // THEN + assertThat(result).isEqualTo(response) + coVerify(exactly = 1) { + exchangeApi.getHistoryDelta(fromAddress = ADDRESS, cursor = DELTA_CURSOR, limit = DEFAULT_LIMIT) + } + coVerify(exactly = 1) { expressHistoryDao.upsertExchanges(listOf(item.toEntity(ADDRESS))) } + } + + // endregion + + // region onramp history + + @Test + fun `GIVEN sync state WHEN fetchOnrampHistory THEN passes after cursor and persists items`() = runTest { + // GIVEN + val item = createOnrampItem() + val response = OnrampHistoryResponse(items = listOf(item), pagination = pagination()) + stubSyncState(ExpressSyncStateEntity.Type.ONRAMP, ADDRESS, syncState(afterCursor = AFTER_CURSOR)) + coEvery { + onrampApi.getHistory(payoutAddress = ADDRESS, afterCursor = AFTER_CURSOR, limit = any()) + } returns ApiResponse.Success(response) + + // WHEN + val result = repository.fetchOnrampHistory(payoutAddress = ADDRESS) + + // THEN + assertThat(result).isEqualTo(response) + coVerify(exactly = 1) { + onrampApi.getHistory(payoutAddress = ADDRESS, afterCursor = AFTER_CURSOR, limit = DEFAULT_LIMIT) + } + coVerify(exactly = 1) { expressHistoryDao.upsertOnramps(listOf(item.toEntity(ADDRESS))) } + } + + @Test + fun `GIVEN no sync state WHEN fetchOnrampHistory THEN passes null cursor`() = runTest { + // GIVEN + val response = OnrampHistoryResponse(items = emptyList(), pagination = pagination()) + stubSyncState(ExpressSyncStateEntity.Type.ONRAMP, ADDRESS, state = null) + coEvery { + onrampApi.getHistory(payoutAddress = ADDRESS, afterCursor = null, limit = any()) + } returns ApiResponse.Success(response) + + // WHEN + repository.fetchOnrampHistory(payoutAddress = ADDRESS) + + // THEN + coVerify(exactly = 1) { + onrampApi.getHistory(payoutAddress = ADDRESS, afterCursor = null, limit = DEFAULT_LIMIT) + } + } + + @Test + fun `GIVEN sync state WHEN fetchOnrampHistoryDelta THEN passes delta cursor and persists items`() = runTest { + // GIVEN + val item = createOnrampItem() + val response = OnrampHistoryDeltaResponse(items = listOf(item), pagination = paginationDelta()) + stubSyncState(ExpressSyncStateEntity.Type.ONRAMP, ADDRESS, syncState(deltaCursor = DELTA_CURSOR)) + coEvery { + onrampApi.getHistoryDelta(payoutAddress = ADDRESS, cursor = DELTA_CURSOR, limit = any()) + } returns ApiResponse.Success(response) + + // WHEN + val result = repository.fetchOnrampHistoryDelta(payoutAddress = ADDRESS) + + // THEN + assertThat(result).isEqualTo(response) + coVerify(exactly = 1) { + onrampApi.getHistoryDelta(payoutAddress = ADDRESS, cursor = DELTA_CURSOR, limit = DEFAULT_LIMIT) + } + coVerify(exactly = 1) { expressHistoryDao.upsertOnramps(listOf(item.toEntity(ADDRESS))) } + } + + @Test + fun `GIVEN api error WHEN fetchOnrampHistory THEN throws and does not persist`() = runTest { + // GIVEN + stubSyncState(ExpressSyncStateEntity.Type.ONRAMP, ADDRESS, syncState(afterCursor = AFTER_CURSOR)) + val error = httpError() + coEvery { + onrampApi.getHistory(payoutAddress = ADDRESS, afterCursor = AFTER_CURSOR, limit = any()) + } returns ApiResponse.Error(error).cast() + + // WHEN + val thrown = runCatching { repository.fetchOnrampHistory(payoutAddress = ADDRESS) }.exceptionOrNull() + + // THEN + assertThat(thrown).isEqualTo(error) + coVerify(exactly = 0) { expressHistoryDao.upsertOnramps(any()) } + } + + // endregion + + // region syncState + + @Test + fun `GIVEN stored sync state WHEN syncState THEN returns first emitted value`() = runTest { + // GIVEN + val state = syncState(afterCursor = AFTER_CURSOR, deltaCursor = DELTA_CURSOR) + stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, state) + + // WHEN + val result = repository.syncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS) + + // THEN + assertThat(result).isEqualTo(state) + } + + @Test + fun `GIVEN multiple items WHEN fetchExchangeHistory THEN maps every item with owner address`() = runTest { + // GIVEN + val items = listOf( + createExchangeItem(txId = "tx-1"), + createExchangeItem(txId = "tx-2"), + ) + val response = ExchangeHistoryResponse(items = items, pagination = pagination()) + stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, syncState(afterCursor = AFTER_CURSOR)) + coEvery { + exchangeApi.getHistory(fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = any()) + } returns ApiResponse.Success(response) + val saved = slot>() + coEvery { expressHistoryDao.upsertExchanges(capture(saved)) } returns Unit + + // WHEN + repository.fetchExchangeHistory(fromAddress = ADDRESS) + + // THEN + assertThat(saved.captured).isEqualTo(items.map { it.toEntity(ADDRESS) }) + assertThat(saved.captured.map { it.ownerAddress }.toSet()).containsExactly(ADDRESS) + } + + // endregion + + private fun stubSyncState(type: ExpressSyncStateEntity.Type, address: String, state: ExpressSyncStateEntity?) { + coEvery { expressSyncStateDao.observe(type = type.name, address = address) } returns flowOf(state) + } + + private fun syncState(afterCursor: String? = null, deltaCursor: String? = null) = ExpressSyncStateEntity( + type = ExpressSyncStateEntity.Type.EXCHANGE.name, + address = ADDRESS, + isInitialCompleted = true, + afterCursor = afterCursor, + deltaCursor = deltaCursor, + ) + + private fun pagination() = ExpressPagination(endCursor = "end", startDeltaCursor = "delta", hasMore = false) + + private fun paginationDelta() = ExpressPaginationDelta(startCursor = "start", hasMore = false) + + @Suppress("UNCHECKED_CAST") + private fun ApiResponse.Error.cast(): ApiResponse = this as ApiResponse + + private fun httpError() = ApiResponseError.HttpException( + code = ApiResponseError.HttpException.Code.INTERNAL_SERVER_ERROR, + message = "boom", + errorBody = null, + ) + + private fun createExchangeItem(txId: String = "exchange-tx-1") = ExchangeItemResponse( + txId = txId, + providerId = "changelly", + fromAddress = "0xfrom", + payinAddress = "0xpayin", + payinExtraId = null, + payoutAddress = "0xpayout", + refundAddress = null, + refundExtraId = null, + rateType = "float", + status = ExchangeItemResponse.Status.FINISHED, + externalTxId = null, + externalTxStatus = null, + externalTxUrl = null, + payinHash = "payin-hash", + payoutHash = "payout-hash", + refundNetwork = null, + refundContractAddress = null, + createdAt = "2026-06-01T00:00:00Z", + payTill = null, + averageDuration = null, + fromContractAddress = "0xfromContract", + fromNetwork = "ethereum", + fromDecimals = 18, + fromAmount = "1.0", + toContractAddress = "0xtoContract", + toNetwork = "bitcoin", + toDecimals = 8, + toAmount = "1.0", + toActualAmount = "0.99", + ) + + private fun createOnrampItem(txId: String = "onramp-tx-1") = OnrampItemResponse( + txId = txId, + providerId = "mercuryo", + fromAddress = "0xfrom", + payinAddress = "0xpayin", + payinExtraId = null, + payoutAddress = "0xpayout", + refundAddress = null, + refundExtraId = null, + rateType = "fixed", + status = OnrampItemResponse.Status.FINISHED, + externalTxId = null, + externalTxStatus = null, + externalTxUrl = null, + payinHash = "payin-hash", + payoutHash = "payout-hash", + refundNetwork = null, + refundContractAddress = null, + createdAt = "2026-06-01T00:00:00Z", + payTill = null, + averageDuration = null, + fromContractAddress = "0xfromContract", + fromNetwork = "usd", + fromDecimals = 2, + fromAmount = "100.0", + toContractAddress = "0xtoContract", + toNetwork = "bitcoin", + toDecimals = 8, + toAmount = "0.001", + toActualAmount = "0.99", + ) + + private companion object { + const val ADDRESS = "0xowner" + const val AFTER_CURSOR = "after-cursor" + const val DELTA_CURSOR = "delta-cursor" + const val DEFAULT_LIMIT = 100 + } +} \ No newline at end of file diff --git a/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/converter/ExpressHistoryConverterTest.kt b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/converter/ExpressHistoryConverterTest.kt new file mode 100644 index 0000000000..a9e137adaf --- /dev/null +++ b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/converter/ExpressHistoryConverterTest.kt @@ -0,0 +1,316 @@ +package com.tangem.data.txhistory.repository.converter + +import com.google.common.truth.Truth.assertThat +import com.tangem.datasource.api.express.models.response.ExchangeItemResponse +import com.tangem.datasource.api.onramp.models.response.OnrampItemResponse +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class ExpressHistoryConverterTest { + + @Test + fun `GIVEN exchange item WHEN toEntity THEN all transaction fields are mapped`() { + // GIVEN + val item = createExchangeItem() + + // WHEN + val entity = item.toEntity(ownerAddress = OWNER_ADDRESS) + + // THEN + assertThat(entity.txId).isEqualTo(item.txId) + assertThat(entity.ownerAddress).isEqualTo(OWNER_ADDRESS) + assertThat(entity.providerId).isEqualTo(item.providerId) + assertThat(entity.fromAddress).isEqualTo(item.fromAddress) + assertThat(entity.payinAddress).isEqualTo(item.payinAddress) + assertThat(entity.payinExtraId).isEqualTo(item.payinExtraId) + assertThat(entity.payoutAddress).isEqualTo(item.payoutAddress) + assertThat(entity.refundAddress).isEqualTo(item.refundAddress) + assertThat(entity.refundExtraId).isEqualTo(item.refundExtraId) + assertThat(entity.rateType).isEqualTo(item.rateType) + assertThat(entity.externalTxId).isEqualTo(item.externalTxId) + assertThat(entity.externalTxStatus).isEqualTo(item.externalTxStatus) + assertThat(entity.externalTxUrl).isEqualTo(item.externalTxUrl) + assertThat(entity.payinHash).isEqualTo(item.payinHash) + assertThat(entity.payoutHash).isEqualTo(item.payoutHash) + assertThat(entity.refundNetwork).isEqualTo(item.refundNetwork) + assertThat(entity.refundContractAddress).isEqualTo(item.refundContractAddress) + assertThat(entity.createdAt).isEqualTo(item.createdAt) + assertThat(entity.payTill).isEqualTo(item.payTill) + assertThat(entity.averageDuration).isEqualTo(item.averageDuration) + } + + @Test + fun `GIVEN exchange item WHEN toEntity THEN status is stored as enum name`() { + // GIVEN + val item = createExchangeItem(status = ExchangeItemResponse.Status.FINISHED) + + // WHEN + val entity = item.toEntity(ownerAddress = OWNER_ADDRESS) + + // THEN + assertThat(entity.status).isEqualTo("FINISHED") + } + + @Test + fun `GIVEN exchange item WHEN toEntity THEN from and to assets are mapped`() { + // GIVEN + val item = createExchangeItem() + + // WHEN + val entity = item.toEntity(ownerAddress = OWNER_ADDRESS) + + // THEN + assertThat(entity.from.contractAddress).isEqualTo(item.fromContractAddress) + assertThat(entity.from.network).isEqualTo(item.fromNetwork) + assertThat(entity.from.decimals).isEqualTo(item.fromDecimals) + assertThat(entity.from.amount).isEqualTo(item.fromAmount) + // `from` asset never carries an actual amount + assertThat(entity.from.actualAmount).isNull() + + assertThat(entity.to.contractAddress).isEqualTo(item.toContractAddress) + assertThat(entity.to.network).isEqualTo(item.toNetwork) + assertThat(entity.to.decimals).isEqualTo(item.toDecimals) + assertThat(entity.to.amount).isEqualTo(item.toAmount) + assertThat(entity.to.actualAmount).isEqualTo(item.toActualAmount) + } + + @Test + fun `GIVEN exchange item with null optional fields WHEN toEntity THEN nulls are preserved`() { + // GIVEN + val item = createExchangeItem( + payinExtraId = null, + refundAddress = null, + refundExtraId = null, + externalTxId = null, + externalTxStatus = null, + externalTxUrl = null, + payinHash = null, + payoutHash = null, + refundNetwork = null, + refundContractAddress = null, + payTill = null, + averageDuration = null, + toActualAmount = null, + ) + + // WHEN + val entity = item.toEntity(ownerAddress = OWNER_ADDRESS) + + // THEN + assertThat(entity.payinExtraId).isNull() + assertThat(entity.refundAddress).isNull() + assertThat(entity.refundExtraId).isNull() + assertThat(entity.externalTxId).isNull() + assertThat(entity.externalTxStatus).isNull() + assertThat(entity.externalTxUrl).isNull() + assertThat(entity.payinHash).isNull() + assertThat(entity.payoutHash).isNull() + assertThat(entity.refundNetwork).isNull() + assertThat(entity.refundContractAddress).isNull() + assertThat(entity.payTill).isNull() + assertThat(entity.averageDuration).isNull() + assertThat(entity.to.actualAmount).isNull() + } + + @Test + fun `GIVEN onramp item WHEN toEntity THEN all transaction fields are mapped`() { + // GIVEN + val item = createOnrampItem() + + // WHEN + val entity = item.toEntity(ownerAddress = OWNER_ADDRESS) + + // THEN + assertThat(entity.txId).isEqualTo(item.txId) + assertThat(entity.ownerAddress).isEqualTo(OWNER_ADDRESS) + assertThat(entity.providerId).isEqualTo(item.providerId) + assertThat(entity.fromAddress).isEqualTo(item.fromAddress) + assertThat(entity.payinAddress).isEqualTo(item.payinAddress) + assertThat(entity.payinExtraId).isEqualTo(item.payinExtraId) + assertThat(entity.payoutAddress).isEqualTo(item.payoutAddress) + assertThat(entity.refundAddress).isEqualTo(item.refundAddress) + assertThat(entity.refundExtraId).isEqualTo(item.refundExtraId) + assertThat(entity.rateType).isEqualTo(item.rateType) + assertThat(entity.externalTxId).isEqualTo(item.externalTxId) + assertThat(entity.externalTxStatus).isEqualTo(item.externalTxStatus) + assertThat(entity.externalTxUrl).isEqualTo(item.externalTxUrl) + assertThat(entity.payinHash).isEqualTo(item.payinHash) + assertThat(entity.payoutHash).isEqualTo(item.payoutHash) + assertThat(entity.refundNetwork).isEqualTo(item.refundNetwork) + assertThat(entity.refundContractAddress).isEqualTo(item.refundContractAddress) + assertThat(entity.createdAt).isEqualTo(item.createdAt) + assertThat(entity.payTill).isEqualTo(item.payTill) + assertThat(entity.averageDuration).isEqualTo(item.averageDuration) + } + + @Test + fun `GIVEN onramp item WHEN toEntity THEN status is stored as enum name`() { + // GIVEN + val item = createOnrampItem(status = OnrampItemResponse.Status.WAITING_TX_HASH) + + // WHEN + val entity = item.toEntity(ownerAddress = OWNER_ADDRESS) + + // THEN + assertThat(entity.status).isEqualTo("WAITING_TX_HASH") + } + + @Test + fun `GIVEN onramp item WHEN toEntity THEN from and to assets are mapped`() { + // GIVEN + val item = createOnrampItem() + + // WHEN + val entity = item.toEntity(ownerAddress = OWNER_ADDRESS) + + // THEN + assertThat(entity.from.contractAddress).isEqualTo(item.fromContractAddress) + assertThat(entity.from.network).isEqualTo(item.fromNetwork) + assertThat(entity.from.decimals).isEqualTo(item.fromDecimals) + assertThat(entity.from.amount).isEqualTo(item.fromAmount) + assertThat(entity.from.actualAmount).isNull() + + assertThat(entity.to.contractAddress).isEqualTo(item.toContractAddress) + assertThat(entity.to.network).isEqualTo(item.toNetwork) + assertThat(entity.to.decimals).isEqualTo(item.toDecimals) + assertThat(entity.to.amount).isEqualTo(item.toAmount) + assertThat(entity.to.actualAmount).isEqualTo(item.toActualAmount) + } + + @Test + fun `GIVEN onramp item with null optional fields WHEN toEntity THEN nulls are preserved`() { + // GIVEN + val item = createOnrampItem( + payinExtraId = null, + refundAddress = null, + refundExtraId = null, + externalTxId = null, + externalTxStatus = null, + externalTxUrl = null, + payinHash = null, + payoutHash = null, + refundNetwork = null, + refundContractAddress = null, + payTill = null, + averageDuration = null, + toActualAmount = null, + ) + + // WHEN + val entity = item.toEntity(ownerAddress = OWNER_ADDRESS) + + // THEN + assertThat(entity.payinExtraId).isNull() + assertThat(entity.refundAddress).isNull() + assertThat(entity.refundExtraId).isNull() + assertThat(entity.externalTxId).isNull() + assertThat(entity.externalTxStatus).isNull() + assertThat(entity.externalTxUrl).isNull() + assertThat(entity.payinHash).isNull() + assertThat(entity.payoutHash).isNull() + assertThat(entity.refundNetwork).isNull() + assertThat(entity.refundContractAddress).isNull() + assertThat(entity.payTill).isNull() + assertThat(entity.averageDuration).isNull() + assertThat(entity.to.actualAmount).isNull() + } + + private fun createExchangeItem( + status: ExchangeItemResponse.Status = ExchangeItemResponse.Status.WAITING, + payinExtraId: String? = "payin-extra", + refundAddress: String? = "refund-address", + refundExtraId: String? = "refund-extra", + externalTxId: String? = "external-tx-id", + externalTxStatus: String? = "external-status", + externalTxUrl: String? = "https://provider.example/tx", + payinHash: String? = "payin-hash", + payoutHash: String? = "payout-hash", + refundNetwork: String? = "ethereum", + refundContractAddress: String? = "0xrefund", + payTill: String? = "2026-06-01T00:10:00Z", + averageDuration: Long? = 600L, + toActualAmount: String? = "0.99", + ) = ExchangeItemResponse( + txId = "exchange-tx-1", + providerId = "changelly", + fromAddress = "0xfrom", + payinAddress = "0xpayin", + payinExtraId = payinExtraId, + payoutAddress = "0xpayout", + refundAddress = refundAddress, + refundExtraId = refundExtraId, + rateType = "float", + status = status, + externalTxId = externalTxId, + externalTxStatus = externalTxStatus, + externalTxUrl = externalTxUrl, + payinHash = payinHash, + payoutHash = payoutHash, + refundNetwork = refundNetwork, + refundContractAddress = refundContractAddress, + createdAt = "2026-06-01T00:00:00Z", + payTill = payTill, + averageDuration = averageDuration, + fromContractAddress = "0xfromContract", + fromNetwork = "ethereum", + fromDecimals = 18, + fromAmount = "1.0", + toContractAddress = "0xtoContract", + toNetwork = "bitcoin", + toDecimals = 8, + toAmount = "1.0", + toActualAmount = toActualAmount, + ) + + private fun createOnrampItem( + status: OnrampItemResponse.Status = OnrampItemResponse.Status.WAITING, + payinExtraId: String? = "payin-extra", + refundAddress: String? = "refund-address", + refundExtraId: String? = "refund-extra", + externalTxId: String? = "external-tx-id", + externalTxStatus: String? = "external-status", + externalTxUrl: String? = "https://provider.example/tx", + payinHash: String? = "payin-hash", + payoutHash: String? = "payout-hash", + refundNetwork: String? = "ethereum", + refundContractAddress: String? = "0xrefund", + payTill: String? = "2026-06-01T00:10:00Z", + averageDuration: Long? = 600L, + toActualAmount: String? = "0.99", + ) = OnrampItemResponse( + txId = "onramp-tx-1", + providerId = "mercuryo", + fromAddress = "0xfrom", + payinAddress = "0xpayin", + payinExtraId = payinExtraId, + payoutAddress = "0xpayout", + refundAddress = refundAddress, + refundExtraId = refundExtraId, + rateType = "fixed", + status = status, + externalTxId = externalTxId, + externalTxStatus = externalTxStatus, + externalTxUrl = externalTxUrl, + payinHash = payinHash, + payoutHash = payoutHash, + refundNetwork = refundNetwork, + refundContractAddress = refundContractAddress, + createdAt = "2026-06-01T00:00:00Z", + payTill = payTill, + averageDuration = averageDuration, + fromContractAddress = "0xfromContract", + fromNetwork = "usd", + fromDecimals = 2, + fromAmount = "100.0", + toContractAddress = "0xtoContract", + toNetwork = "bitcoin", + toDecimals = 8, + toAmount = "0.001", + toActualAmount = toActualAmount, + ) + + private companion object { + const val OWNER_ADDRESS = "0xowner" + } +} \ No newline at end of file