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