Skip to main content

rust_ynab/ynab/
account.rs

1use chrono::DateTime;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use uuid::Uuid;
5
6use crate::Client;
7use crate::Error;
8use crate::PlanId;
9use crate::ynab::common::NO_PARAMS;
10
11#[derive(Debug, Deserialize, Serialize)]
12struct AccountDataEnvelope {
13    data: AccountData,
14}
15
16#[derive(Debug, Deserialize, Serialize)]
17struct AccountData {
18    account: Account,
19}
20
21#[derive(Debug, Deserialize, Serialize)]
22struct AccountsDataEnvelope {
23    data: AccountsData,
24}
25
26#[derive(Debug, Deserialize, Serialize)]
27struct AccountsData {
28    accounts: Vec<Account>,
29    server_knowledge: i64,
30}
31
32/// The type of account.
33#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
34#[non_exhaustive]
35#[serde(rename_all = "camelCase")]
36pub enum AccountType {
37    Checking,
38    Savings,
39    Cash,
40    CreditCard,
41    LineOfCredit,
42    OtherAsset,
43    OtherLiability,
44    Mortgage,
45    AutoLoan,
46    StudentLoan,
47    PersonalLoan,
48    MedicalDebt,
49    OtherDebt,
50    #[serde(other)]
51    Other,
52}
53
54/// The type of account usable to create a new account.
55#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
56#[serde(rename_all = "camelCase")]
57pub enum SaveAccountType {
58    Checking,
59    Savings,
60    Cash,
61    CreditCard,
62    OtherAsset,
63    OtherLiability,
64}
65
66/// A plan account. Amounts are in milliunits (divide by 1000 for display).
67#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
68pub struct Account {
69    pub id: Uuid,
70    pub name: String,
71    #[serde(rename = "type")]
72    pub acct_type: AccountType,
73    pub on_budget: bool,
74    pub closed: bool,
75    pub note: Option<String>,
76    pub balance: i64,
77    pub cleared_balance: i64,
78    pub uncleared_balance: i64,
79    pub transfer_payee_id: Option<uuid::Uuid>,
80    pub direct_import_linked: Option<bool>,
81    pub direct_import_in_error: Option<bool>,
82    pub last_reconciled_at: Option<DateTime<chrono::Utc>>,
83    pub debt_original_balance: Option<i64>, // deprecated
84    // these three are keyed by date string and store milliunits
85    pub debt_interest_rates: Option<HashMap<String, i64>>,
86    pub debt_minimum_payments: Option<HashMap<String, i64>>,
87    pub debt_escrow_amounts: Option<HashMap<String, i64>>,
88    pub deleted: bool,
89}
90
91#[derive(Debug)]
92pub struct GetAccountsBuilder<'a> {
93    client: &'a Client,
94    plan_id: PlanId,
95    last_knowledge_of_server: Option<i64>,
96}
97
98impl<'a> GetAccountsBuilder<'a> {
99    pub fn with_server_knowledge(mut self, sk: i64) -> GetAccountsBuilder<'a> {
100        self.last_knowledge_of_server = Some(sk);
101        self
102    }
103
104    /// Sends the request. Returns accounts and server knowledge for use in subsequent delta requests.
105    pub async fn send(self) -> Result<(Vec<Account>, i64), Error> {
106        let params: Option<&[(&str, &str)]> = if let Some(sk) = self.last_knowledge_of_server {
107            Some(&[("last_knowledge_of_server", &sk.to_string())])
108        } else {
109            None
110        };
111        let result: AccountsDataEnvelope = self
112            .client
113            .get(&format!("plans/{}/accounts", self.plan_id), params)
114            .await?;
115        Ok((result.data.accounts, result.data.server_knowledge))
116    }
117}
118
119impl Client {
120    /// Returns a builder for fetching all accounts. Chain .with_server_knowledge() for a delta request.
121    pub fn get_accounts(&self, plan_id: PlanId) -> GetAccountsBuilder<'_> {
122        GetAccountsBuilder {
123            client: self,
124            plan_id,
125            last_knowledge_of_server: None,
126        }
127    }
128
129    /// Returns a single account.
130    pub async fn get_account(&self, plan_id: PlanId, account_id: Uuid) -> Result<Account, Error> {
131        let result: AccountDataEnvelope = self
132            .get(
133                &format!("plans/{}/accounts/{}", plan_id, account_id),
134                NO_PARAMS,
135            )
136            .await?;
137        Ok(result.data.account)
138    }
139}
140
141impl TryFrom<&str> for SaveAccountType {
142    type Error = String;
143
144    fn try_from(value: &str) -> Result<Self, Self::Error> {
145        match value {
146            "checking" => Ok(Self::Checking),
147            "savings" => Ok(Self::Savings),
148            "cash" => Ok(Self::Cash),
149            "creditCard" => Ok(Self::CreditCard),
150            "otherAsset" => Ok(Self::OtherAsset),
151            "otherLiability" => Ok(Self::OtherLiability),
152            _ => Err(format!("unknown SaveAccount type: {}", value)),
153        }
154    }
155}
156
157/// The account to create.
158#[derive(Debug, Clone, PartialEq, Serialize)]
159pub struct SaveAccount {
160    pub name: String,
161    #[serde(rename = "type")]
162    pub acct_type: SaveAccountType,
163    pub balance: i64,
164}
165
166#[derive(Debug, Serialize)]
167struct SaveAccountBody {
168    account: SaveAccount,
169}
170
171impl Client {
172    /// Creates a new account.
173    pub async fn create_account(
174        &self,
175        plan_id: PlanId,
176        account: SaveAccount,
177    ) -> Result<Account, Error> {
178        let response: AccountDataEnvelope = self
179            .post(
180                &format!("plans/{}/accounts", plan_id),
181                SaveAccountBody { account },
182            )
183            .await?;
184        Ok(response.data.account)
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use crate::ynab::testutil::{TEST_ID_1, account_fixture, error_body, new_test_client};
192    use uuid::uuid;
193    use wiremock::matchers::{method, path};
194    use wiremock::{Mock, ResponseTemplate};
195
196    fn account_list_fixture() -> serde_json::Value {
197        serde_json::json!({
198            "data": {
199                "accounts": [account_fixture(), account_fixture()],
200                "server_knowledge": 7
201            }
202        })
203    }
204
205    fn account_single_fixture() -> serde_json::Value {
206        serde_json::json!({
207            "data": { "account": account_fixture() }
208        })
209    }
210
211    #[tokio::test]
212    async fn get_accounts_returns_ids() {
213        let (client, server) = new_test_client().await;
214
215        Mock::given(method("GET"))
216            .and(path("/plans/last-used/accounts"))
217            .respond_with(ResponseTemplate::new(200).set_body_json(account_list_fixture()))
218            .expect(1)
219            .mount(&server)
220            .await;
221
222        let (accounts, _) = client.get_accounts(PlanId::LastUsed).send().await.unwrap();
223        assert_eq!(accounts.len(), 2);
224        assert!(
225            accounts
226                .iter()
227                .zip([TEST_ID_1, TEST_ID_1])
228                .all(|(a, id)| a.id.to_string() == id)
229        );
230    }
231
232    #[tokio::test]
233    async fn get_account_returns_id() {
234        let (client, server) = new_test_client().await;
235
236        Mock::given(method("GET"))
237            .and(path(format!("/plans/last-used/accounts/{}", TEST_ID_1)))
238            .respond_with(ResponseTemplate::new(200).set_body_json(account_single_fixture()))
239            .expect(1)
240            .mount(&server)
241            .await;
242
243        let account = client
244            .get_account(PlanId::LastUsed, uuid!(TEST_ID_1))
245            .await
246            .unwrap();
247        assert_eq!(account.id.to_string(), TEST_ID_1);
248    }
249
250    #[tokio::test]
251    async fn get_account_returns_not_found() {
252        let (client, server) = new_test_client().await;
253
254        Mock::given(method("GET"))
255            .and(path(format!("/plans/last-used/accounts/{}", TEST_ID_1)))
256            .respond_with(ResponseTemplate::new(404).set_body_json(error_body(
257                "404",
258                "not_found",
259                "Account not found",
260            )))
261            .mount(&server)
262            .await;
263
264        let err = client
265            .get_account(PlanId::LastUsed, TEST_ID_1.parse().unwrap())
266            .await
267            .unwrap_err();
268        assert!(matches!(err, Error::NotFound(_)));
269    }
270
271    #[tokio::test]
272    async fn create_account_succeeds() {
273        let (client, server) = new_test_client().await;
274
275        let input_account = account_fixture();
276        let account = SaveAccount {
277            name: input_account["name"].as_str().unwrap().to_string(),
278            acct_type: SaveAccountType::try_from(input_account["type"].as_str().unwrap()).unwrap(),
279            balance: input_account["balance"].as_i64().unwrap(),
280        };
281
282        Mock::given(method("POST"))
283            .and(path(format!("/plans/{}/accounts", TEST_ID_1)))
284            .respond_with(ResponseTemplate::new(200).set_body_json(account_single_fixture()))
285            .mount(&server)
286            .await;
287
288        let account_response = client
289            .create_account(PlanId::Id(uuid!(TEST_ID_1)), account)
290            .await
291            .unwrap();
292        assert_eq!(account_response.id.to_string(), TEST_ID_1);
293        assert_eq!(
294            account_response.name,
295            input_account["name"].as_str().unwrap()
296        );
297        assert_eq!(
298            account_response.balance,
299            input_account["balance"].as_i64().unwrap()
300        );
301        assert_eq!(
302            account_response.deleted,
303            input_account["deleted"].as_bool().unwrap()
304        );
305    }
306
307    #[tokio::test]
308    async fn create_account_returns_bad_request() {
309        let (client, server) = new_test_client().await;
310
311        Mock::given(method("POST"))
312            .and(path(format!("/plans/{}/accounts", TEST_ID_1)))
313            .respond_with(ResponseTemplate::new(400).set_body_json(error_body(
314                "400",
315                "bad_request",
316                "Bad Request",
317            )))
318            .mount(&server)
319            .await;
320
321        let account = SaveAccount {
322            name: "A bad bad name".to_string(),
323            acct_type: SaveAccountType::Cash,
324            balance: -500,
325        };
326        let err = client
327            .create_account(PlanId::Id(uuid!(TEST_ID_1)), account)
328            .await
329            .unwrap_err();
330        assert!(matches!(err, Error::BadRequest(_)));
331    }
332
333    #[tokio::test]
334    async fn create_account_returns_conflict() {
335        let (client, server) = new_test_client().await;
336
337        Mock::given(method("POST"))
338            .and(path(format!("/plans/{}/accounts", TEST_ID_1)))
339            .respond_with(
340                ResponseTemplate::new(409).set_body_json(error_body("409", "conflict", "Conflict")),
341            )
342            .mount(&server)
343            .await;
344
345        let account = SaveAccount {
346            name: "A conflicting conflicting name".to_string(),
347            acct_type: SaveAccountType::Cash,
348            balance: -500,
349        };
350        let err = client
351            .create_account(PlanId::Id(uuid!(TEST_ID_1)), account)
352            .await
353            .unwrap_err();
354        assert!(matches!(err, Error::Conflict(_)));
355    }
356}