Skip to main content

rust_ynab/ynab/
account.rs

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