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}