From ef94f641b5053f33e6d3537215812d1fc671882d Mon Sep 17 00:00:00 2001
From: Vincent Texier <vit@free.fr>
Date: Wed, 2 Apr 2025 17:04:57 +0200
Subject: [PATCH] [feat] add Export as OFX in transfer_history_menu

---
 tikka/adapters/network/indexer/transfers.py   | 146 +++++++++---------
 tikka/domains/application.py                  |   3 +-
 tikka/domains/indexers.py                     |  22 ++-
 tikka/domains/nodes.py                        |  22 ++-
 tikka/domains/transfers.py                    | 133 ++++++++++++++++
 .../adapters/network/indexer/transfers.py     |  10 +-
 tikka/slots/pyqt/entities/constants.py        |   3 +
 .../resources/gui/windows/transfers_export.ui |  55 +++++++
 .../pyqt/widgets/transfer_history_menu.py     |  15 ++
 tikka/slots/pyqt/windows/transfers_export.py  | 145 +++++++++++++++++
 10 files changed, 447 insertions(+), 107 deletions(-)
 create mode 100644 tikka/slots/pyqt/resources/gui/windows/transfers_export.ui
 create mode 100644 tikka/slots/pyqt/windows/transfers_export.py

diff --git a/tikka/adapters/network/indexer/transfers.py b/tikka/adapters/network/indexer/transfers.py
index c205fdcd..dea28ec5 100644
--- a/tikka/adapters/network/indexer/transfers.py
+++ b/tikka/adapters/network/indexer/transfers.py
@@ -34,84 +34,82 @@ class IndexerTransfers(IndexerTransfersInterface):
 
     def list(
         self,
-        address,
+        address: str,
         limit: int,
+        offset: int = 0,
         sort_column: str = IndexerTransfersInterface.COLUMN_TIMESTAMP,
         sort_order: str = IndexerTransfersInterface.SORT_ORDER_DESCENDING,
-        since: Optional[datetime] = None,
+        from_datetime: Optional[datetime] = None,
+        to_datetime: Optional[datetime] = None,
     ) -> List[Transfer]:
         __doc__ = (  # pylint: disable=redefined-builtin, unused-variable
             IndexerTransfersInterface.list.__doc__
         )
+
         if (
             not self.indexer.connection.is_connected()
             or self.indexer.connection.client is None
         ):
             raise IndexerTransfersException(NetworkConnectionError())
 
-        since_str = ""
-        if since is not None:
-            since_str = (
-                """_and: {
-                        timestamp: {_gte: \""""
-                + since.isoformat()
-                + """\"}
-                    }"""
+        # Construire la condition de filtrage pour la date
+        date_filter = []
+        if from_datetime:
+            date_filter.append(
+                f'{{ timestamp: {{ _gte: "{from_datetime.isoformat()}" }} }}'
+            )
+        if to_datetime:
+            date_filter.append(
+                f'{{ timestamp: {{ _lte: "{to_datetime.isoformat()}" }} }}'
             )
 
+        date_filter_str = (
+            "_and: [" + ", ".join(date_filter) + "]" if date_filter else ""
+        )
+
+        # Construire la requête GraphQL
         query = gql(
-            """
-            query {
+            f"""
+            query {{
               transfer(
-                limit: """
-            + str(limit)
-            + """
-                orderBy: {
-                    """
-            + sort_column
-            + """: """
-            + sort_order
-            + """
-                }
-                where: {
+                limit: {limit}
+                offset: {offset}
+                orderBy: {{
+                    {sort_column}: {sort_order}
+                }}
+                where: {{
                     _or: [
-                        {fromId: {_eq: \""""
-            + address
-            + """\"}}
-                        {toId: {_eq: \""""
-            + address
-            + """\"}}
+                        {{ fromId: {{ _eq: \"{address}\" }} }}
+                        {{ toId: {{ _eq: \"{address}\" }} }}
                     ]
-                    """
-            + since_str
-            + """
-                }
-              ) {
+                    {date_filter_str}
+                }}
+              ) {{
                     id
                     fromId
-                    from {
-                      identity {
+                    from {{
+                      identity {{
                         index
                         name
-                      }
-                    }
+                      }}
+                    }}
                     toId
-                    to {
-                      identity {
+                    to {{
+                      identity {{
                         index
                         name
-                      }
-                    }
+                      }}
+                    }}
                     amount
                     timestamp
-                    comment {
+                    comment {{
                       type
                       remark
                       remarkBytes
-                    }
-                }
-            }
-            """
+                    }}
+                }}
+            }}
+        """
         )
 
         try:
@@ -119,43 +117,38 @@ class IndexerTransfers(IndexerTransfersInterface):
         except Exception as exception:
             raise NetworkIndexerException(exception)
 
-        # {'transfer': [
-        # {'id': '0000056159-1d1ed-000003',
-        #  'fromId': '5Dq8xjvkmbz7q4g2LbZgyExD26VSCutfEc6n4W4AfQeVHZqz',
-        #  'from': {'identity': {'index': 344, 'name': 'xxxx'}},
-        #  'toId': '5CQ8T4qpbYJq7uVsxGPQ5q2df7x3Wa4aRY6HUWMBYjfLZhnn',
-        #  'to': {'identity': {'index': 61, 'name': 'yyyy'}},
-        #  'amount': 123,
-        #  'timestamp': '2024-02-08T14:59:00.001+00:00',
-        #  'comment': {'remark': 'test 2', 'type': 'ASCII'},
-        # ]}
-
         transfers = []
-
         for transfer in result["transfer"]:
-            if transfer["from"]["identity"] is not None:
-                issuer_identity_index = transfer["from"]["identity"]["index"]
-                issuer_identity_name = transfer["from"]["identity"]["name"]
-            else:
-                issuer_identity_index = None
-                issuer_identity_name = None
-            if transfer["to"]["identity"] is not None:
-                receiver_identity_index = transfer["to"]["identity"]["index"]
-                receiver_identity_name = transfer["to"]["identity"]["name"]
-            else:
-                receiver_identity_index = None
-                receiver_identity_name = None
-
-            if transfer["comment"] is not None:
+            issuer_identity_index = (
+                transfer["from"]["identity"]["index"]
+                if transfer["from"]["identity"]
+                else None
+            )
+            issuer_identity_name = (
+                transfer["from"]["identity"]["name"]
+                if transfer["from"]["identity"]
+                else None
+            )
+            receiver_identity_index = (
+                transfer["to"]["identity"]["index"]
+                if transfer["to"]["identity"]
+                else None
+            )
+            receiver_identity_name = (
+                transfer["to"]["identity"]["name"]
+                if transfer["to"]["identity"]
+                else None
+            )
+
+            comment = None
+            comment_type = None
+            if transfer["comment"]:
                 comment = (
                     transfer["comment"]["remarkBytes"]
                     if transfer["comment"]["type"] == "RAW"
                     else transfer["comment"]["remark"]
                 )
                 comment_type = transfer["comment"]["type"]
-            else:
-                comment = None
-                comment_type = None
 
             transfers.append(
                 Transfer(
@@ -172,6 +165,7 @@ class IndexerTransfers(IndexerTransfersInterface):
                     comment_type=comment_type,
                 )
             )
+
         return transfers
 
     def count(self, address) -> int:
diff --git a/tikka/domains/application.py b/tikka/domains/application.py
index daea2853..20752bee 100644
--- a/tikka/domains/application.py
+++ b/tikka/domains/application.py
@@ -116,6 +116,7 @@ class Application:
         self.transfers = Transfers(
             self.wallets,
             self.repository.transfers,
+            self.currencies,
             self.network.node.transfers,
             self.network.indexer.transfers,
             self.event_dispatcher,
@@ -139,7 +140,6 @@ class Application:
         self.nodes = Nodes(
             self.repository.nodes,
             self.preferences,
-            self.connections,
             self.network.node,
             self.config,
             self.currencies,
@@ -148,7 +148,6 @@ class Application:
         self.indexers = Indexers(
             self.repository.indexers,
             self.preferences,
-            self.connections,
             self.network.indexer,
             self.config,
             self.currencies,
diff --git a/tikka/domains/indexers.py b/tikka/domains/indexers.py
index a5db9e6b..2af4aa8d 100644
--- a/tikka/domains/indexers.py
+++ b/tikka/domains/indexers.py
@@ -21,7 +21,6 @@ from urllib import request
 
 from tikka.adapters.network.indexer.indexer import NetworkIndexer
 from tikka.domains.config import Config
-from tikka.domains.connections import Connections
 from tikka.domains.currencies import Currencies
 from tikka.domains.entities.constants import (
     INDEXERS_CURRENT_ENTRY_POINT_URL_PREFERENCES_KEY,
@@ -46,8 +45,7 @@ class Indexers:
         self,
         repository: IndexersRepositoryInterface,
         preferences: Preferences,
-        connections: Connections,
-        network: NetworkIndexerInterface,
+        network_indexer: NetworkIndexerInterface,
         config: Config,
         currencies: Currencies,
         event_dispatcher: EventDispatcher,
@@ -57,16 +55,14 @@ class Indexers:
 
         :param repository: EntryPointsRepositoryInterface instance
         :param preferences: Preferences domain instance
-        :param connections: Connections instance
-        :param network: Network adapter instance for handling indexers
+        :param network_indexer: Network adapter instance for handling indexers
         :param config: Config instance
         :param currencies: Currencies instance
         :param event_dispatcher: EventDispatcher instance
         """
         self.repository = repository
         self.preferences = preferences
-        self.connections = connections
-        self.network = network
+        self.network_indexer = network_indexer
         self.config = config
         self.currencies = currencies
         self.event_dispatcher = event_dispatcher
@@ -79,7 +75,7 @@ class Indexers:
         # events
         self.event_dispatcher.add_event_listener(
             ConnectionsEvent.EVENT_TYPE_INDEXER_CONNECTED,
-            self._on_connections_connected,
+            self._on_indexer_connected_event,
         )
 
     def init_repository(self):
@@ -135,7 +131,7 @@ class Indexers:
         :return:
         """
         current_indexer = self.repository.get(self.get_current_url())
-        network_indexer = self.network.get()
+        network_indexer = self.network_indexer.get()
         if network_indexer is None:
             return None
 
@@ -180,7 +176,7 @@ class Indexers:
             # never choose localhost randomly...
             if "localhost" not in url:
                 self.set_current_url(self.list()[index].url)
-                if self.connections.indexer.is_connected():
+                if self.network_indexer.connection.is_connected():
                     break
 
     def add(self, indexer: Indexer) -> None:
@@ -291,12 +287,12 @@ class Indexers:
             self._current_url,
         )
         # switch current connection
-        self.connections.indexer.disconnect()
+        self.network_indexer.connection.disconnect()
         indexer = self.get(self._current_url)
         if indexer is not None:
-            self.connections.indexer.connect(indexer)
+            self.network_indexer.connection.connect(indexer)
 
-    def _on_connections_connected(self, _: ConnectionsEvent):
+    def _on_indexer_connected_event(self, _: ConnectionsEvent):
         """
         Triggered when the connection is established
 
diff --git a/tikka/domains/nodes.py b/tikka/domains/nodes.py
index 2f4b9dd4..d1954d44 100644
--- a/tikka/domains/nodes.py
+++ b/tikka/domains/nodes.py
@@ -21,7 +21,6 @@ from urllib import request
 
 from tikka.adapters.network.node.node import NetworkNode
 from tikka.domains.config import Config
-from tikka.domains.connections import Connections
 from tikka.domains.currencies import Currencies
 from tikka.domains.entities.constants import (
     NODES_CURRENT_ENTRY_POINT_URL_PREFERENCES_KEY,
@@ -46,8 +45,7 @@ class Nodes:
         self,
         repository: NodesRepositoryInterface,
         preferences: Preferences,
-        connections: Connections,
-        network: NetworkNodeInterface,
+        network_node: NetworkNodeInterface,
         config: Config,
         currencies: Currencies,
         event_dispatcher: EventDispatcher,
@@ -57,16 +55,14 @@ class Nodes:
 
         :param repository: EntryPointsRepositoryInterface instance
         :param preferences: Preferences domain instance
-        :param connections: Connections instance
-        :param network: NetworkNodeInterface adapter instance for handling nodes
+        :param network_node: NetworkNodeInterface adapter instance for handling nodes
         :param config: Config instance
         :param currencies: Currencies instance
         :param event_dispatcher: EventDispatcher instance
         """
         self.repository = repository
         self.preferences = preferences
-        self.connections = connections
-        self.network = network
+        self.network_node = network_node
         self.config = config
         self.currencies = currencies
         self.event_dispatcher = event_dispatcher
@@ -78,7 +74,7 @@ class Nodes:
 
         # events
         self.event_dispatcher.add_event_listener(
-            ConnectionsEvent.EVENT_TYPE_NODE_CONNECTED, self._on_connections_connected
+            ConnectionsEvent.EVENT_TYPE_NODE_CONNECTED, self._on_node_connected_event
         )
 
     def init_repository(self):
@@ -138,7 +134,7 @@ class Nodes:
         :return:
         """
         current_node = self.repository.get(self.get_current_url())
-        network_node = self.network.get()
+        network_node = self.network_node.get()
         if network_node is None:
             return None
 
@@ -191,7 +187,7 @@ class Nodes:
             # never choose localhost randomly...
             if "localhost" not in url:
                 self.set_current_url(self.list()[index].url)
-                if self.connections.node.is_connected():
+                if self.network_node.connection.is_connected():
                     break
 
     def add(self, node: Node) -> None:
@@ -297,12 +293,12 @@ class Nodes:
             self._current_url,
         )
         # switch current connection
-        self.connections.node.disconnect()
+        self.network_node.connection.disconnect()
         node = self.get(self._current_url)
         if node is not None:
-            self.connections.node.connect(node)
+            self.network_node.connection.connect(node)
 
-    def _on_connections_connected(self, _: ConnectionsEvent):
+    def _on_node_connected_event(self, _: ConnectionsEvent):
         """
         Triggered when the connection is established
 
diff --git a/tikka/domains/transfers.py b/tikka/domains/transfers.py
index 88dac0a7..eeb7a7e3 100644
--- a/tikka/domains/transfers.py
+++ b/tikka/domains/transfers.py
@@ -12,9 +12,12 @@
 #
 # You should have received a copy of the GNU General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
+import logging
+from datetime import datetime
 from typing import Any, Dict, List, Optional
 
 from tikka.adapters.network.node.substrate_client import ExtrinsicReceipt
+from tikka.domains.currencies import Currencies
 from tikka.domains.entities.account import Account
 from tikka.domains.entities.events import TransferEvent
 from tikka.domains.entities.transfer import Transfer
@@ -28,6 +31,16 @@ from tikka.interfaces.adapters.repository.transfers import TransfersRepositoryIn
 from tikka.libs.keypair import Keypair
 
 
+def format_datetime(dt: datetime) -> str:
+    """
+    Formate une date en OFX (YYYYMMDDHHMMSS).
+
+    :param dt: Datetime data
+    :return:
+    """
+    return dt.strftime("%Y%m%d%H%M%S")
+
+
 class Transfers:
     """
     Transfers domain class
@@ -37,6 +50,7 @@ class Transfers:
         self,
         wallets: Wallets,
         repository: TransfersRepositoryInterface,
+        currencies: Currencies,
         node_transfers: NodeTransfersInterface,
         indexer_transfers: IndexerTransfersInterface,
         event_dispatcher: EventDispatcher,
@@ -46,11 +60,13 @@ class Transfers:
 
         :param wallets: Wallets domain
         :param repository: TransfersRepositoryInterface adapter instance
+        :param currencies: Currencies domain instance
         :param node_transfers: NodeTransfersInterface adapter instance
         :param event_dispatcher: EventDispatcher instance
         """
         self.wallets = wallets
         self.repository = repository
+        self.currencies = currencies
         self.node_transfers = node_transfers
         self.indexer_transfers = indexer_transfers
         self.event_dispatcher = event_dispatcher
@@ -225,3 +241,120 @@ class Transfers:
             self.event_dispatcher.dispatch_event(event)
 
         return receipt
+
+    def export_as_ofx(
+        self,
+        filepath: str,
+        address: str,
+        from_datetime: Optional[datetime] = None,
+        to_datetime: Optional[datetime] = None,
+    ):
+        """
+        Export transfers for address in an OFX format file
+
+        :param filepath: Path of the file
+        :param address: Account address
+        :param from_datetime: Optional period start date
+        :param to_datetime: Optional period end date
+        :return:
+        """
+        batch_size = 100
+        offset = 0
+
+        with open(filepath, "w", encoding="utf-8") as f:
+            # Écriture de l'en-tête OFX
+            f.write(
+                f"""OFXHEADER:100
+    DATA:OFXSGML
+    VERSION:102
+    SECURITY:NONE
+    ENCODING:USASCII
+    CHARSET:1252
+    COMPRESSION:NONE
+    OLDFILEUID:NONE
+    NEWFILEUID:NONE
+    
+    <OFX>
+        <BANKMSGSRSV1>
+            <STMTTRNRS>
+                <STMTRS>
+                    <CURDEF>EUR</CURDEF>
+                    <BANKACCTFROM>
+                        <BANKID>{self.currencies.get_current().name}</BANKID>
+                        <ACCTID>{address}</ACCTID>
+                        <ACCTTYPE>CHECKING</ACCTTYPE>
+                    </BANKACCTFROM>
+                    <BANKTRANLIST>
+    """
+            )
+
+            while True:
+                # Récupérer un lot de 100 transferts depuis GraphQL
+                transfers = self.indexer_transfers.list(
+                    address=address,
+                    limit=batch_size,
+                    offset=offset,
+                    from_datetime=from_datetime,
+                    to_datetime=to_datetime,
+                )
+
+                if not transfers:
+                    break  # Arrêter si plus de transferts à récupérer
+
+                total_amount: float = 0.0
+                for transfer in transfers:
+                    if transfer.issuer_address != address:
+                        amount_sign = 1
+                        name = (
+                            f"{transfer.issuer_address} - {transfer.issuer_identity_name}#{transfer.issuer_identity_index}"
+                            if transfer.issuer_identity_name
+                            else transfer.issuer_address
+                        )
+                    else:
+                        amount_sign = -1
+                        name = (
+                            f"{transfer.receiver_address} - {transfer.receiver_identity_name}#{transfer.receiver_identity_index}"
+                            if transfer.receiver_identity_name
+                            else transfer.receiver_address
+                        )
+                    total_amount += (
+                        transfer.amount / 100
+                    ) * amount_sign  # Convert cents to dollars
+
+                    f.write(
+                        f"""
+                    <STMTTRN>
+                        <TRNTYPE>XFER</TRNTYPE>
+                        <DTPOSTED>{format_datetime(transfer.timestamp)}</DTPOSTED>
+                        <TRNAMT>{(transfer.amount / 100)*amount_sign:.2f}</TRNAMT>
+                        <FITID>{transfer.id}</FITID>
+                        <NAME>{name}</NAME>
+                        <MEMO>{transfer.comment or ''}</MEMO>
+                    </STMTTRN>
+                    """
+                    )
+
+                offset += batch_size  # Passer au lot suivant
+
+            current_datetime = format_datetime(datetime.utcnow())
+
+            # Écriture du pied de fichier OFX
+            f.write(
+                f"""
+                    </BANKTRANLIST>
+                    <LEDGERBAL>
+                        <BALAMT>{total_amount:.2f}</BALAMT>
+                        <DTASOF>{current_datetime}</DTASOF>
+                    </LEDGERBAL>
+                    <AVAILBAL>
+                        <BALAMT>{total_amount:.2f}</BALAMT>
+                        <DTASOF>{current_datetime}</DTASOF>
+                    </AVAILBAL>
+                </STMTRS>
+            </STMTTRNRS>
+        </BANKMSGSRSV1>
+    </OFX>
+    """
+            )
+
+        logging.debug(f"Export OFX terminé : {filepath}")
diff --git a/tikka/interfaces/adapters/network/indexer/transfers.py b/tikka/interfaces/adapters/network/indexer/transfers.py
index 4e01bda9..5ba17b6d 100644
--- a/tikka/interfaces/adapters/network/indexer/transfers.py
+++ b/tikka/interfaces/adapters/network/indexer/transfers.py
@@ -43,20 +43,24 @@ class IndexerTransfersInterface(abc.ABC):
     @abc.abstractmethod
     def list(
         self,
-        address,
+        address: str,
         limit: int,
+        offset: int = 0,
         sort_column: str = COLUMN_TIMESTAMP,
         sort_order: str = SORT_ORDER_DESCENDING,
-        since: Optional[datetime] = None,
+        from_datetime: Optional[datetime] = None,
+        to_datetime: Optional[datetime] = None,
     ) -> List[Transfer]:
         """
         Return list of transfers from and to address
 
         :param address: Account address
         :param limit: Max number of transfers to return
+        :param offset: Offset to paginate results
         :param sort_column: Sort column, default to IndexerTransfersInterface.COLUMN_TIMESTAMP
         :param sort_order: Sort order, default to IndexerTransfersInterface.SORT_ORDER_DESCENDING
-        :param since: Only transfers since datetime, default to None
+        :param from_datetime: Only transfers from datetime, default to None
+        :param to_datetime: Only transfers until datetime, default to None
         :return:
         """
         raise NotImplementedError
diff --git a/tikka/slots/pyqt/entities/constants.py b/tikka/slots/pyqt/entities/constants.py
index fc15cd98..68991c78 100644
--- a/tikka/slots/pyqt/entities/constants.py
+++ b/tikka/slots/pyqt/entities/constants.py
@@ -60,3 +60,6 @@ INDEXERS_TABLE_SORT_COLUMN_PREFERENCES_KEY = "indexers_table_sort_column"
 INDEXERS_TABLE_SORT_ORDER_PREFERENCES_KEY = "indexers_table_sort_order"
 TABS_PREFERENCES_KEY = "tabs_opened"
 SAVE_DATA_DEFAULT_DIR_PREFERENCES_KEY = "save_data_default_dir"
+TRANSFERS_EXPORT_DEFAULT_DIRECTORY_PREFERENCES_KEY = (
+    "transfers_export_default_directory"
+)
diff --git a/tikka/slots/pyqt/resources/gui/windows/transfers_export.ui b/tikka/slots/pyqt/resources/gui/windows/transfers_export.ui
new file mode 100644
index 00000000..b77e8ad5
--- /dev/null
+++ b/tikka/slots/pyqt/resources/gui/windows/transfers_export.ui
@@ -0,0 +1,55 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>transfersExportDialog</class>
+ <widget class="QDialog" name="transfersExportDialog">
+  <property name="geometry">
+   <rect>
+    <x>0</x>
+    <y>0</y>
+    <width>801</width>
+    <height>457</height>
+   </rect>
+  </property>
+  <property name="windowTitle">
+   <string>Export as</string>
+  </property>
+  <layout class="QFormLayout" name="formLayout">
+   <item row="0" column="0">
+    <widget class="QLabel" name="fromLabel">
+     <property name="text">
+      <string>From</string>
+     </property>
+    </widget>
+   </item>
+   <item row="0" column="1">
+    <widget class="QCalendarWidget" name="fromCalendarWidget"/>
+   </item>
+   <item row="1" column="0">
+    <widget class="QLabel" name="toLabel">
+     <property name="text">
+      <string>To</string>
+     </property>
+    </widget>
+   </item>
+   <item row="1" column="1">
+    <widget class="QCalendarWidget" name="toCalendarWidget"/>
+   </item>
+   <item row="2" column="1">
+    <widget class="QDialogButtonBox" name="buttonBox">
+     <property name="standardButtons">
+      <set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
+     </property>
+    </widget>
+   </item>
+   <item row="2" column="0">
+    <widget class="QPushButton" name="todayButton">
+     <property name="text">
+      <string>Today</string>
+     </property>
+    </widget>
+   </item>
+  </layout>
+ </widget>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/tikka/slots/pyqt/widgets/transfer_history_menu.py b/tikka/slots/pyqt/widgets/transfer_history_menu.py
index c2b068cf..961bf48e 100644
--- a/tikka/slots/pyqt/widgets/transfer_history_menu.py
+++ b/tikka/slots/pyqt/widgets/transfer_history_menu.py
@@ -24,6 +24,7 @@ from tikka.domains.entities.account import Account
 from tikka.domains.entities.constants import DATA_PATH
 from tikka.domains.entities.transfer import Transfer
 from tikka.slots.pyqt.windows.transfer import TransferWindow
+from tikka.slots.pyqt.windows.transfers_export import TransfersExportWindow
 
 
 class TransferHistoryPopupMenu(QMenu):
@@ -96,6 +97,10 @@ class TransferHistoryPopupMenu(QMenu):
             )
             add_account_address_action.triggered.connect(self.add_address_to_accounts)
 
+        if self.application.connections.indexer.is_connected():
+            export_ofx_action = self.addAction(self._("Export in OFX file"))
+            export_ofx_action.triggered.connect(self.export_ofx)
+
     def copy_address_to_clipboard(self):
         """
         Copy address of selected row to clipboard
@@ -149,6 +154,16 @@ class TransferHistoryPopupMenu(QMenu):
 
         self.application.accounts.add(self.contact_account)
 
+    def export_ofx(self):
+        """
+        Export transfers in an OFX file
+
+        :return:
+        """
+        TransfersExportWindow(
+            self.application, self.account.address, self.parentWidget()
+        ).exec_()
+
 
 if __name__ == "__main__":
     qapp = QApplication(sys.argv)
diff --git a/tikka/slots/pyqt/windows/transfers_export.py b/tikka/slots/pyqt/windows/transfers_export.py
new file mode 100644
index 00000000..6ed7422b
--- /dev/null
+++ b/tikka/slots/pyqt/windows/transfers_export.py
@@ -0,0 +1,145 @@
+# Copyright 2021 Vincent Texier <vit@free.fr>
+#
+# This software is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This software is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+from pathlib import Path
+from typing import Optional
+
+from PyQt5.QtCore import QDate
+from PyQt5.QtWidgets import QDialog, QFileDialog, QWidget
+
+from tikka.domains.application import Application
+from tikka.slots.pyqt.entities.constants import (
+    TRANSFERS_EXPORT_DEFAULT_DIRECTORY_PREFERENCES_KEY,
+)
+from tikka.slots.pyqt.resources.gui.windows.transfers_export_rc import (
+    Ui_transfersExportDialog,
+)
+
+
+class TransfersExportWindow(QDialog, Ui_transfersExportDialog):
+    """
+    TransfersExportWindow class
+    """
+
+    MIN_PERIOD_IN_WEEKS = 4
+    MAX_PERIOD_IN_WEEKS = 104
+
+    def __init__(
+        self, application: Application, address: str, parent: Optional[QWidget] = None
+    ):
+        """
+        Init transfers export window instance
+
+        :param application: Application instance
+        :param address: Account address
+        :param parent: QWidget instance
+        """
+        super().__init__(parent=parent)
+        self.setupUi(self)
+
+        self.application = application
+        self._ = self.application.translator.gettext
+        self.address = address
+
+        # buttons
+        self.buttonBox.accepted.connect(self.on_accepted_button)
+        self.buttonBox.rejected.connect(self.close)
+        self.fromCalendarWidget.selectionChanged.connect(self.update_date_constraints)
+        self.toCalendarWidget.selectionChanged.connect(self.update_date_constraints)
+        self.todayButton.clicked.connect(self.init_calendar_limits)
+        self.init_calendar_limits()
+
+    def init_calendar_limits(self):
+        """
+        Définit les dates minimales et maximales autorisées pour les calendriers,
+        en se basant sur la période maximale définie.
+        """
+        today = QDate.currentDate()
+        default_min_date = today.addDays(-self.MIN_PERIOD_IN_WEEKS * 7)
+
+        # Définir la plage de dates pour le calendrier "from"
+        # self.fromCalendarWidget.setDateRange(max_min_date, today)
+        self.fromCalendarWidget.setSelectedDate(default_min_date)
+
+        # Définir la plage de dates pour le calendrier "to"
+        # self.toCalendarWidget.setDateRange(min_date, max_date)
+        self.toCalendarWidget.setSelectedDate(today)
+
+    def update_date_constraints(self):
+        """
+        Update calendars with date constraints
+        """
+        from_date = self.fromCalendarWidget.selectedDate()
+        to_date = self.toCalendarWidget.selectedDate()
+        min_to_date = from_date.addDays(self.MIN_PERIOD_IN_WEEKS * 7)
+        max_from_date = to_date.addDays(-self.MAX_PERIOD_IN_WEEKS * 7)
+
+        if from_date > to_date:
+            self.fromCalendarWidget.setSelectedDate(to_date)
+        elif to_date < min_to_date:
+            self.toCalendarWidget.setSelectedDate(min_to_date)
+
+        max_to_date = from_date.addDays(self.MAX_PERIOD_IN_WEEKS * 7)
+
+        if to_date > max_to_date:
+            self.toCalendarWidget.setSelectedDate(max_to_date)
+        if from_date < max_from_date:
+            self.fromCalendarWidget.setSelectedDate(max_from_date)
+        # self.toCalendarWidget.setMinimumDate(from_date)
+        # self.toCalendarWidget.setMaximumDate(max_to_date)
+        # self.fromCalendarWidget.setMaximumDate(to_date)
+
+    def open_file_dialog(self) -> Optional[str]:
+        """
+        Open file dialog and return the selected filepath or None
+
+        :return:
+        """
+        default_dir = self.application.repository.preferences.get(
+            TRANSFERS_EXPORT_DEFAULT_DIRECTORY_PREFERENCES_KEY
+        )
+        if default_dir is not None:
+            default_dir = str(Path(default_dir).expanduser().absolute())
+        else:
+            default_dir = ""
+
+        result = QFileDialog.getSaveFileName(
+            self, self._("Export file"), default_dir, "OFX Files (*.ofx)"
+        )
+        if result[0] == "":
+            return None
+
+        self.application.repository.preferences.set(
+            TRANSFERS_EXPORT_DEFAULT_DIRECTORY_PREFERENCES_KEY,
+            str(Path(result[0]).parent),
+        )
+
+        return result[0]
+
+    def on_accepted_button(self) -> None:
+        """
+        Triggered when user click on ok button
+
+        :return:
+        """
+        # open file dialog
+        filepath = self.open_file_dialog()
+        if filepath is not None:
+            self.application.transfers.export_as_ofx(
+                filepath,
+                self.address,
+                self.fromCalendarWidget.selectedDate().toPyDate(),
+                self.toCalendarWidget.selectedDate().toPyDate(),
+            )
+        self.close()
-- 
GitLab