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, Clone, PartialEq, Eq, Hash, 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, Clone, PartialEq, 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
120impl TryFrom<&str> for AccountType {
121    type Error = String;
122
123    fn try_from(value: &str) -> Result<Self, Self::Error> {
124        match value {
125            "checking" => Ok(AccountType::Checking),
126            "savings" => Ok(AccountType::Savings),
127            "cash" => Ok(AccountType::Cash),
128            "creditCard" => Ok(AccountType::CreditCard),
129            "lineOfCredit" => Ok(AccountType::LineOfCredit),
130            "otherAsset" => Ok(AccountType::OtherAsset),
131            "otherLiability" => Ok(AccountType::OtherLiability),
132            "mortgage" => Ok(AccountType::Mortgage),
133            "autoLoan" => Ok(AccountType::AutoLoan),
134            "studentLoan" => Ok(AccountType::StudentLoan),
135            "personalLoan" => Ok(AccountType::PersonalLoan),
136            "medicalDebt" => Ok(AccountType::MedicalDebt),
137            "otherDebt" => Ok(AccountType::OtherDebt),
138            _ => Err(format!("unknown account type: {}", value)),
139        }
140    }
141}
142
143/// The account to create.
144#[derive(Debug, Clone, PartialEq, Serialize)]
145pub struct SaveAccount {
146    pub name: String,
147    #[serde(rename = "type")]
148    pub acct_type: AccountType,
149    pub balance: i64,
150}
151
152#[derive(Debug, Serialize)]
153struct SaveAccountBody {
154    account: SaveAccount,
155}
156
157impl Client {
158    /// Creates a new account.
159    pub async fn create_account(
160        &self,
161        plan_id: PlanId,
162        account: SaveAccount,
163    ) -> Result<Account, Error> {
164        let response: AccountDataEnvelope = self
165            .post(
166                &format!("plans/{}/accounts", plan_id),
167                SaveAccountBody { account },
168            )
169            .await?;
170        Ok(response.data.account)
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use crate::ynab::testutil::{TEST_ID_1, account_fixture, error_body, new_test_client};
178    use uuid::uuid;
179    use wiremock::matchers::{method, path};
180    use wiremock::{Mock, ResponseTemplate};
181
182    fn account_list_fixture() -> serde_json::Value {
183        serde_json::json!({
184            "data": {
185                "accounts": [account_fixture(), account_fixture()],
186                "server_knowledge": 7
187            }
188        })
189    }
190
191    fn account_single_fixture() -> serde_json::Value {
192        serde_json::json!({
193            "data": { "account": account_fixture() }
194        })
195    }
196
197    #[tokio::test]
198    async fn get_accounts_returns_ids() {
199        let (client, server) = new_test_client().await;
200
201        Mock::given(method("GET"))
202            .and(path("/plans/last-used/accounts"))
203            .respond_with(ResponseTemplate::new(200).set_body_json(account_list_fixture()))
204            .expect(1)
205            .mount(&server)
206            .await;
207
208        let (accounts, _) = client.get_accounts(PlanId::LastUsed).send().await.unwrap();
209        assert_eq!(accounts.len(), 2);
210        assert!(
211            accounts
212                .iter()
213                .zip([TEST_ID_1, TEST_ID_1])
214                .all(|(a, id)| a.id.to_string() == id)
215        );
216    }
217
218    #[tokio::test]
219    async fn get_account_returns_id() {
220        let (client, server) = new_test_client().await;
221
222        Mock::given(method("GET"))
223            .and(path(format!("/plans/last-used/accounts/{}", TEST_ID_1)))
224            .respond_with(ResponseTemplate::new(200).set_body_json(account_single_fixture()))
225            .expect(1)
226            .mount(&server)
227            .await;
228
229        let account = client
230            .get_account(PlanId::LastUsed, uuid!(TEST_ID_1))
231            .await
232            .unwrap();
233        assert_eq!(account.id.to_string(), TEST_ID_1);
234    }
235
236    #[tokio::test]
237    async fn get_account_returns_not_found() {
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(404).set_body_json(error_body(
243                "404",
244                "not_found",
245                "Account not found",
246            )))
247            .mount(&server)
248            .await;
249
250        let err = client
251            .get_account(PlanId::LastUsed, TEST_ID_1.parse().unwrap())
252            .await
253            .unwrap_err();
254        assert!(matches!(err, Error::NotFound(_)));
255    }
256
257    #[tokio::test]
258    async fn create_account_succeeds() {
259        let (client, server) = new_test_client().await;
260
261        let input_account = account_fixture();
262        let account = SaveAccount {
263            name: input_account["name"].as_str().unwrap().to_string(),
264            acct_type: AccountType::try_from(input_account["type"].as_str().unwrap()).unwrap(),
265            balance: input_account["balance"].as_i64().unwrap(),
266        };
267
268        Mock::given(method("POST"))
269            .and(path(format!("/plans/{}/accounts", TEST_ID_1)))
270            .respond_with(ResponseTemplate::new(200).set_body_json(account_single_fixture()))
271            .mount(&server)
272            .await;
273
274        let account_response = client
275            .create_account(PlanId::Id(uuid!(TEST_ID_1)), account)
276            .await
277            .unwrap();
278        assert_eq!(account_response.id.to_string(), TEST_ID_1);
279        assert_eq!(
280            account_response.name,
281            input_account["name"].as_str().unwrap()
282        );
283        assert_eq!(
284            account_response.balance,
285            input_account["balance"].as_i64().unwrap()
286        );
287        assert_eq!(
288            account_response.deleted,
289            input_account["deleted"].as_bool().unwrap()
290        );
291    }
292
293    #[tokio::test]
294    async fn create_account_returns_bad_request() {
295        let (client, server) = new_test_client().await;
296
297        Mock::given(method("POST"))
298            .and(path(format!("/plans/{}/accounts", TEST_ID_1)))
299            .respond_with(ResponseTemplate::new(400).set_body_json(error_body(
300                "400",
301                "bad_request",
302                "Bad Request",
303            )))
304            .mount(&server)
305            .await;
306
307        let account = SaveAccount {
308            name: "A bad bad name".to_string(),
309            acct_type: AccountType::Cash,
310            balance: -500,
311        };
312        let err = client
313            .create_account(PlanId::Id(uuid!(TEST_ID_1)), account)
314            .await
315            .unwrap_err();
316        assert!(matches!(err, Error::BadRequest(_)));
317    }
318
319    #[tokio::test]
320    async fn create_account_returns_conflict() {
321        let (client, server) = new_test_client().await;
322
323        Mock::given(method("POST"))
324            .and(path(format!("/plans/{}/accounts", TEST_ID_1)))
325            .respond_with(
326                ResponseTemplate::new(409).set_body_json(error_body("409", "conflict", "Conflict")),
327            )
328            .mount(&server)
329            .await;
330
331        let account = SaveAccount {
332            name: "A conflicting conflicting name".to_string(),
333            acct_type: AccountType::Cash,
334            balance: -500,
335        };
336        let err = client
337            .create_account(PlanId::Id(uuid!(TEST_ID_1)), account)
338            .await
339            .unwrap_err();
340        assert!(matches!(err, Error::Conflict(_)));
341    }
342}