Skip to main content

nordnet_api/resources/
accounts.rs

1//! Resource methods for the `accounts` API group.
2//! # Operations
3//! | Method | Op | Path |
4//! |--------|----|------|
5//! | GET | `list_accounts` | `/accounts` |
6//! | GET | `get_account_info` | `/accounts/{accid}/info` |
7//! | GET | `list_ledgers` | `/accounts/{accid}/ledgers` |
8//! | GET | `list_positions` | `/accounts/{accid}/positions` |
9//! | GET | `get_returns_today` | `/accounts/{accid}/returns/transactions/today` |
10//! | GET | `list_account_trades` | `/accounts/{accid}/trades` |
11//!
12//! ## Naming
13//! One op is renamed from its docs name so it can co-exist on [`Client`]
14//! alongside same-named ops in other groups (Rust resolves all resource
15//! methods onto a single `Client` impl):
16//! - `list_trades` -> `list_account_trades` — to coexist with
17//!   [`Client::list_tradable_trades`] (`tradables` group) and
18//!   [`Client::list_instrument_trades`] (`instruments` group). Mirrors the
19//!   precedent set in `resources/instruments.rs`.
20//!   Phase 3X may pick a uniform naming scheme.
21//!
22//! ## Path note
23//! `get_returns_today` uses path `/accounts/{accid}/returns/transactions/today`
24//! per the Phase 1 docs extract. The Phase 3 task brief proposed
25//! `/accounts/{accid}/returns/today` but the saved HTML schema is the
26//! authoritative source priority #1.
27//!
28//! ## 204 No Content
29//! All ops except `get_account_info` document a 204 response. The base
30//! [`Client::get`] surfaces an empty body as a [`Error::Decode`]; each
31//! Vec-returning method here maps that case to an empty `Vec` (mirroring
32//! the `instruments` precedent). `list_ledgers` returns a single
33//! [`LedgerInformation`] object (not an array) — its 204 case is bubbled
34//! up as the underlying decode error rather than fabricated, matching the
35//! `instruments::get_leverage_filters` precedent for non-array returns.
36
37use crate::client::Client;
38use crate::error::Error;
39use nordnet_model::ids::AccountId;
40use nordnet_model::models::accounts::{
41    Account, AccountInfo, AccountTransactionsToday, LedgerInformation, Position, Trade,
42};
43
44/// Optional query parameters for [`Client::list_accounts`].
45#[derive(Debug, Clone, Default)]
46pub struct ListAccountsQuery {
47    /// `true` if credit accounts should be included in the response.
48    /// Defaults to `false` server-side.
49    pub include_credit_accounts: Option<bool>,
50}
51
52/// Optional query parameters for [`Client::get_account_info`].
53#[derive(Debug, Clone, Default)]
54pub struct AccountInfoQuery {
55    /// `true` if `interest_rate` should be included in the response.
56    /// Defaults to `true` server-side.
57    pub include_interest_rate: Option<bool>,
58    /// `true` if `short_position_margin` should be included in the
59    /// response. Defaults to `true` server-side.
60    pub include_short_pos_margin: Option<bool>,
61}
62
63/// Optional query parameters for [`Client::list_positions`].
64#[derive(Debug, Clone, Default)]
65pub struct ListPositionsQuery {
66    /// `true` if instrument loan positions should be included.
67    /// Defaults to `false` server-side.
68    pub include_instrument_loans: Option<bool>,
69    /// `true` if intraday limit should be included.
70    /// Defaults to `false` server-side.
71    pub include_intraday_limit: Option<bool>,
72}
73
74/// Optional query parameters for [`Client::get_returns_today`].
75#[derive(Debug, Clone, Default)]
76pub struct ReturnsTodayQuery {
77    /// `true` if credit accounts should be included.
78    /// Defaults to `true` server-side.
79    pub include_credit_account: Option<bool>,
80}
81
82/// Optional query parameters for [`Client::list_account_trades`].
83#[derive(Debug, Clone, Default)]
84pub struct ListAccountTradesQuery {
85    /// Number of days to look up trades for. Defaults to `0` (today only)
86    /// server-side. Maximum is `7` per the docs.
87    pub days: Option<i64>,
88}
89
90/// Build the encoded query string for the given pairs.
91/// Uses `reqwest::Url::query_pairs_mut` so all percent-encoding follows
92/// the standard URL form rules. The placeholder host is never sent
93/// anywhere — only the encoded query suffix is extracted.
94fn build_query(pairs: &[(&str, String)]) -> String {
95    if pairs.is_empty() {
96        return String::new();
97    }
98    let mut url = match reqwest::Url::parse("http://_/") {
99        Ok(u) => u,
100        // The literal above is a valid absolute URL — this branch is
101        // unreachable in practice. Returning an empty string keeps the
102        // function total without panicking.
103        Err(_) => return String::new(),
104    };
105    {
106        let mut qs = url.query_pairs_mut();
107        for (k, v) in pairs {
108            qs.append_pair(k, v);
109        }
110    }
111    url.query().unwrap_or("").to_owned()
112}
113
114fn append_bool(pairs: &mut Vec<(&'static str, String)>, k: &'static str, v: Option<bool>) {
115    if let Some(b) = v {
116        pairs.push((k, b.to_string()));
117    }
118}
119
120impl Client {
121    /// `GET /accounts` — Returns a list of accounts to which the user has
122    /// access.
123    ///
124    /// Returns an empty `Vec` on 204 No Content.
125    ///
126    /// # Errors
127    ///
128    /// [`Error::Unauthorized`] (401), [`Error::Forbidden`] (403),
129    /// [`Error::TooManyRequests`] (429), [`Error::ServiceUnavailable`]
130    /// (503).
131    #[doc(alias = "GET /accounts")]
132    pub async fn list_accounts(&self, query: ListAccountsQuery) -> Result<Vec<Account>, Error> {
133        let mut pairs = Vec::new();
134        append_bool(
135            &mut pairs,
136            "include_credit_accounts",
137            query.include_credit_accounts,
138        );
139        let qs = build_query(&pairs);
140        let path = if qs.is_empty() {
141            "/accounts".to_owned()
142        } else {
143            format!("/accounts?{qs}")
144        };
145        match self.get::<Vec<Account>>(&path).await {
146            Ok(v) => Ok(v),
147            Err(Error::Decode { ref body, .. }) if body.trim().is_empty() => Ok(vec![]),
148            Err(e) => Err(e),
149        }
150    }
151
152    /// `GET /accounts/{accid}/info` — Returns account information details
153    /// for one or more accounts.
154    ///
155    /// `accid` is one or more account identifier(s). The single-account
156    /// `accid: AccountId` parameter is the strongly-typed shape; multi-
157    /// account lookups (the API accepts a comma-separated list in the
158    /// path) are deferred to a higher-level helper.
159    ///
160    /// # Errors
161    ///
162    /// [`Error::BadRequest`] (400), [`Error::Unauthorized`] (401),
163    /// [`Error::Forbidden`] (403), [`Error::TooManyRequests`] (429),
164    /// [`Error::ServiceUnavailable`] (503).
165    #[doc(alias = "GET /accounts/{accid}/info")]
166    pub async fn get_account_info(
167        &self,
168        accid: AccountId,
169        query: AccountInfoQuery,
170    ) -> Result<Vec<AccountInfo>, Error> {
171        let mut pairs = Vec::new();
172        append_bool(
173            &mut pairs,
174            "include_interest_rate",
175            query.include_interest_rate,
176        );
177        append_bool(
178            &mut pairs,
179            "include_short_pos_margin",
180            query.include_short_pos_margin,
181        );
182        let qs = build_query(&pairs);
183        let path = if qs.is_empty() {
184            format!("/accounts/{accid}/info")
185        } else {
186            format!("/accounts/{accid}/info?{qs}")
187        };
188        self.get::<Vec<AccountInfo>>(&path).await
189    }
190
191    /// `GET /accounts/{accid}/ledgers` — Returns information about the
192    /// currency ledgers of an account.
193    ///
194    /// Note: 204 is documented but cannot be mapped to an empty default
195    /// because [`LedgerInformation`] is a single object (not an array).
196    /// In that case the underlying [`Error::Decode`] is returned.
197    ///
198    /// # Errors
199    ///
200    /// [`Error::BadRequest`] (400), [`Error::Unauthorized`] (401),
201    /// [`Error::Forbidden`] (403), [`Error::TooManyRequests`] (429),
202    /// [`Error::ServiceUnavailable`] (503).
203    #[doc(alias = "GET /accounts/{accid}/ledgers")]
204    pub async fn list_ledgers(&self, accid: AccountId) -> Result<LedgerInformation, Error> {
205        let path = format!("/accounts/{accid}/ledgers");
206        self.get::<LedgerInformation>(&path).await
207    }
208
209    /// `GET /accounts/{accid}/positions` — Returns all positions for the
210    /// given account.
211    ///
212    /// Returns an empty `Vec` on 204 No Content.
213    ///
214    /// # Errors
215    ///
216    /// [`Error::BadRequest`] (400), [`Error::Unauthorized`] (401),
217    /// [`Error::Forbidden`] (403), [`Error::TooManyRequests`] (429),
218    /// [`Error::ServiceUnavailable`] (503).
219    #[doc(alias = "GET /accounts/{accid}/positions")]
220    pub async fn list_positions(
221        &self,
222        accid: AccountId,
223        query: ListPositionsQuery,
224    ) -> Result<Vec<Position>, Error> {
225        let mut pairs = Vec::new();
226        append_bool(
227            &mut pairs,
228            "include_instrument_loans",
229            query.include_instrument_loans,
230        );
231        append_bool(
232            &mut pairs,
233            "include_intraday_limit",
234            query.include_intraday_limit,
235        );
236        let qs = build_query(&pairs);
237        let path = if qs.is_empty() {
238            format!("/accounts/{accid}/positions")
239        } else {
240            format!("/accounts/{accid}/positions?{qs}")
241        };
242        match self.get::<Vec<Position>>(&path).await {
243            Ok(v) => Ok(v),
244            Err(Error::Decode { ref body, .. }) if body.trim().is_empty() => Ok(vec![]),
245            Err(e) => Err(e),
246        }
247    }
248
249    /// `GET /accounts/{accid}/returns/transactions/today` — Returns
250    /// today's withdrawal/deposit transaction amounts for the given
251    /// account.
252    ///
253    /// Returns an empty `Vec` on 204 No Content.
254    ///
255    /// # Errors
256    ///
257    /// [`Error::BadRequest`] (400), [`Error::Unauthorized`] (401),
258    /// [`Error::Forbidden`] (403), [`Error::TooManyRequests`] (429),
259    /// [`Error::ServiceUnavailable`] (503).
260    #[doc(alias = "GET /accounts/{accid}/returns/transactions/today")]
261    pub async fn get_returns_today(
262        &self,
263        accid: AccountId,
264        query: ReturnsTodayQuery,
265    ) -> Result<Vec<AccountTransactionsToday>, Error> {
266        let mut pairs = Vec::new();
267        append_bool(
268            &mut pairs,
269            "include_credit_account",
270            query.include_credit_account,
271        );
272        let qs = build_query(&pairs);
273        let path = if qs.is_empty() {
274            format!("/accounts/{accid}/returns/transactions/today")
275        } else {
276            format!("/accounts/{accid}/returns/transactions/today?{qs}")
277        };
278        match self.get::<Vec<AccountTransactionsToday>>(&path).await {
279            Ok(v) => Ok(v),
280            Err(Error::Decode { ref body, .. }) if body.trim().is_empty() => Ok(vec![]),
281            Err(e) => Err(e),
282        }
283    }
284
285    /// `GET /accounts/{accid}/trades` — Returns all trades belonging to
286    /// the given account.
287    ///
288    /// Renamed from the docs op `list_trades` to `list_account_trades` to
289    /// coexist with [`Client::list_tradable_trades`] and
290    /// [`Client::list_instrument_trades`] on the same `Client` impl.
291    ///
292    /// Returns an empty `Vec` on 204 No Content.
293    ///
294    /// # Errors
295    ///
296    /// [`Error::BadRequest`] (400), [`Error::Unauthorized`] (401),
297    /// [`Error::Forbidden`] (403), 404 (documented as "Account not found";
298    /// surfaced via [`Error::UnexpectedStatus`] because the foundation
299    /// `Error` enum does not model 404 as a dedicated variant),
300    /// [`Error::TooManyRequests`] (429),
301    /// [`Error::ServiceUnavailable`] (503).
302    #[doc(alias = "GET /accounts/{accid}/trades")]
303    pub async fn list_account_trades(
304        &self,
305        accid: AccountId,
306        query: ListAccountTradesQuery,
307    ) -> Result<Vec<Trade>, Error> {
308        let mut pairs = Vec::new();
309        if let Some(d) = query.days {
310            pairs.push(("days", d.to_string()));
311        }
312        let qs = build_query(&pairs);
313        let path = if qs.is_empty() {
314            format!("/accounts/{accid}/trades")
315        } else {
316            format!("/accounts/{accid}/trades?{qs}")
317        };
318        match self.get::<Vec<Trade>>(&path).await {
319            Ok(v) => Ok(v),
320            Err(Error::Decode { ref body, .. }) if body.trim().is_empty() => Ok(vec![]),
321            Err(e) => Err(e),
322        }
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    #[test]
331    fn build_query_empty_when_no_pairs() {
332        assert_eq!(build_query(&[]), "");
333    }
334
335    #[test]
336    fn build_query_pairs_in_order_and_encoded() {
337        let qs = build_query(&[
338            ("days", "7".to_owned()),
339            ("include_credit_accounts", "true".to_owned()),
340            ("name", "a&b".to_owned()),
341        ]);
342        assert_eq!(qs, "days=7&include_credit_accounts=true&name=a%26b");
343    }
344
345    #[test]
346    fn append_bool_skips_when_none() {
347        let mut pairs = Vec::new();
348        append_bool(&mut pairs, "include_credit_accounts", None);
349        assert!(pairs.is_empty());
350    }
351
352    #[test]
353    fn append_bool_emits_lowercase_when_some() {
354        let mut pairs = Vec::new();
355        append_bool(&mut pairs, "include_credit_accounts", Some(true));
356        append_bool(&mut pairs, "include_intraday_limit", Some(false));
357        assert_eq!(
358            pairs,
359            vec![
360                ("include_credit_accounts", "true".to_owned()),
361                ("include_intraday_limit", "false".to_owned()),
362            ]
363        );
364    }
365}