Skip to main content

oanda_rs/endpoints/
transactions.rs

1//! Transaction endpoints: paging, lookup, and range queries over an
2//! account's transaction history.
3
4use serde::{Deserialize, Serialize};
5
6use crate::client::Client;
7use crate::error::Error;
8use crate::models::transaction::{Transaction, TransactionFilter};
9use crate::models::{AccountId, DateTime, TransactionId};
10use crate::streaming::{
11    StreamConfig, StreamKind, TransactionKind, TransactionStream, stream_config_setters,
12};
13
14impl Client {
15    /// Get a list of transaction pages that satisfy a time-based
16    /// transaction query. The response contains page URLs, not
17    /// transactions; fetch pages via
18    /// [`Client::transactions_id_range`].
19    ///
20    /// `GET /v3/accounts/{accountID}/transactions`
21    pub fn list_transactions(&self, account_id: impl Into<AccountId>) -> ListTransactionsRequest {
22        ListTransactionsRequest {
23            client: self.clone(),
24            account_id: account_id.into(),
25            params: Vec::new(),
26        }
27    }
28
29    /// Get the details of a single account transaction.
30    ///
31    /// `GET /v3/accounts/{accountID}/transactions/{transactionID}`
32    pub async fn transaction(
33        &self,
34        account_id: impl Into<AccountId>,
35        transaction_id: impl Into<TransactionId>,
36    ) -> Result<TransactionResponse, Error> {
37        let account_id = account_id.into();
38        let transaction_id = transaction_id.into();
39        self.execute(self.get(&[
40            "accounts",
41            account_id.as_str(),
42            "transactions",
43            transaction_id.as_str(),
44        ]))
45        .await
46    }
47
48    /// Get a range of transactions for an account based on transaction
49    /// IDs.
50    ///
51    /// `GET /v3/accounts/{accountID}/transactions/idrange`
52    pub fn transactions_id_range(
53        &self,
54        account_id: impl Into<AccountId>,
55        from: impl Into<TransactionId>,
56        to: impl Into<TransactionId>,
57    ) -> TransactionsIdRangeRequest {
58        TransactionsIdRangeRequest {
59            client: self.clone(),
60            account_id: account_id.into(),
61            from: from.into(),
62            to: to.into(),
63            types: None,
64        }
65    }
66
67    /// Open a stream of the account's transactions (plus a heartbeat
68    /// every 5 seconds).
69    ///
70    /// The returned [`TransactionStream`] manages its own connection: it
71    /// detects stale connections via the heartbeat, reconnects with capped
72    /// exponential backoff, and **back-fills transactions missed while
73    /// disconnected** via [`Client::transactions_since_id`], deduplicating
74    /// by transaction ID. See [`crate::streaming`] for details and tuning.
75    ///
76    /// Connects to the **streaming host**
77    /// (`stream-fxpractice`/`stream-fxtrade`). OANDA allows at most 20
78    /// active streams per IP.
79    ///
80    /// `GET /v3/accounts/{accountID}/transactions/stream`
81    ///
82    /// # Examples
83    ///
84    /// ```no_run
85    /// # async fn run() -> Result<(), oanda_rs::Error> {
86    /// # let client = oanda_rs::Client::new(oanda_rs::Environment::Practice, "token");
87    /// use futures_util::StreamExt;
88    /// use oanda_rs::models::transaction::TransactionStreamItem;
89    ///
90    /// let mut stream = client
91    ///     .transaction_stream("101-004-1234567-001")
92    ///     .send()
93    ///     .await?;
94    /// while let Some(item) = stream.next().await {
95    ///     if let TransactionStreamItem::Transaction(tx) = item? {
96    ///         println!("{:?}: {:?}", tx.type_name(), tx.id());
97    ///     }
98    /// }
99    /// # Ok(())
100    /// # }
101    /// ```
102    pub fn transaction_stream(&self, account_id: impl Into<AccountId>) -> TransactionStreamRequest {
103        TransactionStreamRequest {
104            client: self.clone(),
105            account_id: account_id.into(),
106            config: StreamConfig::default(),
107        }
108    }
109
110    /// Get a range of transactions for an account starting at (but not
111    /// including) a provided transaction ID.
112    ///
113    /// This is the canonical way to back-fill transactions missed while a
114    /// transaction stream was disconnected.
115    ///
116    /// `GET /v3/accounts/{accountID}/transactions/sinceid`
117    pub async fn transactions_since_id(
118        &self,
119        account_id: impl Into<AccountId>,
120        id: impl Into<TransactionId>,
121    ) -> Result<TransactionsResponse, Error> {
122        let account_id = account_id.into();
123        let id = id.into();
124        let request = self
125            .get(&["accounts", account_id.as_str(), "transactions", "sinceid"])
126            .query(&[("id", id.as_str())]);
127        self.execute(request).await
128    }
129}
130
131/// Builder for [`Client::transaction_stream`].
132#[derive(Debug)]
133pub struct TransactionStreamRequest {
134    client: Client,
135    account_id: AccountId,
136    config: StreamConfig,
137}
138
139impl TransactionStreamRequest {
140    stream_config_setters!();
141
142    /// Connects and returns the managed stream. Fails fast when the
143    /// initial connection is rejected (e.g. bad token or account).
144    pub async fn send(self) -> Result<TransactionStream, Error> {
145        let mut kind = TransactionKind {
146            client: self.client,
147            account_id: self.account_id,
148            last_seen: None,
149        };
150        let initial = kind.connect(false).await?;
151        Ok(TransactionStream::new(kind, self.config, initial))
152    }
153}
154
155/// Builder for [`Client::list_transactions`].
156#[derive(Debug)]
157pub struct ListTransactionsRequest {
158    client: Client,
159    account_id: AccountId,
160    params: Vec<(&'static str, String)>,
161}
162
163impl ListTransactionsRequest {
164    /// The starting time (inclusive) of the time range (default: account
165    /// creation time).
166    pub fn from(mut self, from: impl Into<DateTime>) -> Self {
167        self.params.push(("from", from.into().0));
168        self
169    }
170
171    /// The ending time (inclusive) of the time range (default: request
172    /// time).
173    pub fn to(mut self, to: impl Into<DateTime>) -> Self {
174        self.params.push(("to", to.into().0));
175        self
176    }
177
178    /// The number of transactions to include in each page (default 100,
179    /// maximum 1000).
180    pub fn page_size(mut self, page_size: u32) -> Self {
181        self.params.push(("pageSize", page_size.to_string()));
182        self
183    }
184
185    /// Filters the transactions by type.
186    pub fn types<I>(mut self, types: I) -> Self
187    where
188        I: IntoIterator<Item = TransactionFilter>,
189    {
190        let joined = types
191            .into_iter()
192            .map(|t| t.as_str().to_owned())
193            .collect::<Vec<_>>()
194            .join(",");
195        self.params.push(("type", joined));
196        self
197    }
198
199    /// Performs the request.
200    pub async fn send(self) -> Result<ListTransactionsResponse, Error> {
201        let request = self
202            .client
203            .get(&["accounts", self.account_id.as_str(), "transactions"])
204            .query(&self.params);
205        self.client.execute(request).await
206    }
207}
208
209/// Response of [`Client::list_transactions`].
210#[derive(Debug, Clone, Serialize, Deserialize)]
211#[non_exhaustive]
212pub struct ListTransactionsResponse {
213    /// The starting time provided in the request.
214    #[serde(rename = "from", skip_serializing_if = "Option::is_none")]
215    pub from: Option<DateTime>,
216    /// The ending time provided in the request.
217    #[serde(rename = "to", skip_serializing_if = "Option::is_none")]
218    pub to: Option<DateTime>,
219    /// The page size provided in the request.
220    #[serde(rename = "pageSize", skip_serializing_if = "Option::is_none")]
221    pub page_size: Option<i64>,
222    /// The transaction-type filter provided in the request.
223    #[serde(rename = "type", default, skip_serializing_if = "Vec::is_empty")]
224    pub r#type: Vec<TransactionFilter>,
225    /// The number of transactions that are contained in the pages.
226    #[serde(rename = "count", skip_serializing_if = "Option::is_none")]
227    pub count: Option<i64>,
228    /// The list of URLs that represent idrange queries providing the data
229    /// for each page in the query results.
230    #[serde(rename = "pages", default, skip_serializing_if = "Vec::is_empty")]
231    pub pages: Vec<String>,
232    /// The ID of the most recent transaction created for the account.
233    #[serde(rename = "lastTransactionID", skip_serializing_if = "Option::is_none")]
234    pub last_transaction_id: Option<TransactionId>,
235}
236
237/// Response of [`Client::transaction`].
238#[derive(Debug, Clone, Serialize, Deserialize)]
239#[non_exhaustive]
240pub struct TransactionResponse {
241    /// The details of the requested transaction.
242    #[serde(rename = "transaction")]
243    pub transaction: Transaction,
244    /// The ID of the most recent transaction created for the account.
245    #[serde(rename = "lastTransactionID", skip_serializing_if = "Option::is_none")]
246    pub last_transaction_id: Option<TransactionId>,
247}
248
249/// Builder for [`Client::transactions_id_range`].
250#[derive(Debug)]
251pub struct TransactionsIdRangeRequest {
252    client: Client,
253    account_id: AccountId,
254    from: TransactionId,
255    to: TransactionId,
256    types: Option<Vec<TransactionFilter>>,
257}
258
259impl TransactionsIdRangeRequest {
260    /// Filters the transactions by type.
261    pub fn types<I>(mut self, types: I) -> Self
262    where
263        I: IntoIterator<Item = TransactionFilter>,
264    {
265        self.types = Some(types.into_iter().collect());
266        self
267    }
268
269    /// Performs the request.
270    pub async fn send(self) -> Result<TransactionsResponse, Error> {
271        let mut request = self
272            .client
273            .get(&[
274                "accounts",
275                self.account_id.as_str(),
276                "transactions",
277                "idrange",
278            ])
279            .query(&[("from", self.from.as_str()), ("to", self.to.as_str())]);
280        if let Some(types) = &self.types {
281            let joined = types
282                .iter()
283                .map(|t| t.as_str().to_owned())
284                .collect::<Vec<_>>()
285                .join(",");
286            request = request.query(&[("type", joined)]);
287        }
288        self.client.execute(request).await
289    }
290}
291
292/// Response of [`Client::transactions_id_range`] and
293/// [`Client::transactions_since_id`]: a list of full transaction objects.
294#[derive(Debug, Clone, Serialize, Deserialize)]
295#[non_exhaustive]
296pub struct TransactionsResponse {
297    /// The list of transactions that satisfy the request.
298    #[serde(
299        rename = "transactions",
300        default,
301        skip_serializing_if = "Vec::is_empty"
302    )]
303    pub transactions: Vec<Transaction>,
304    /// The ID of the most recent transaction created for the account.
305    #[serde(rename = "lastTransactionID", skip_serializing_if = "Option::is_none")]
306    pub last_transaction_id: Option<TransactionId>,
307}