Skip to main content

rust_ynab/ynab/
month.rs

1use chrono::NaiveDate;
2use serde::{Deserialize, Serialize};
3
4use crate::PlanId;
5use crate::ynab::category::Category;
6use crate::ynab::client::Client;
7use crate::ynab::common::NO_PARAMS;
8use crate::ynab::errors::Error;
9
10#[derive(Debug, Serialize, Deserialize)]
11struct MonthDataEnvelope {
12    data: MonthData,
13}
14
15#[derive(Debug, Serialize, Deserialize)]
16struct MonthData {
17    month: Month,
18}
19
20#[derive(Debug, Serialize, Deserialize)]
21struct MonthsDataEnvelope {
22    data: MonthsData,
23}
24
25#[derive(Debug, Serialize, Deserialize)]
26struct MonthsData {
27    months: Vec<Month>,
28    server_knowledge: i64,
29}
30
31/// A plan month. This is where Ready to Assign, Age of Money, and category amounts
32/// (assigned, activity, available) are available. Amounts are in milliunits (divide by 1000 for
33/// display).
34#[derive(Debug, Serialize, Deserialize)]
35pub struct Month {
36    pub month: NaiveDate,
37    pub note: Option<String>,
38    pub income: i64,
39    pub budgeted: i64,
40    pub activity: i64,
41    pub to_be_budgeted: i64,
42    pub age_of_money: Option<usize>,
43    pub deleted: bool,
44    #[serde(default)]
45    pub categories: Vec<Category>,
46}
47
48#[derive(Debug)]
49pub struct GetMonthsBuilder<'a> {
50    client: &'a Client,
51    plan_id: PlanId,
52    last_knowledge_of_server: Option<i64>,
53}
54
55impl<'a> GetMonthsBuilder<'a> {
56    pub fn with_server_knowledge(mut self, sk: i64) -> Self {
57        self.last_knowledge_of_server = Some(sk);
58        self
59    }
60
61    pub async fn send(self) -> Result<(Vec<Month>, i64), Error> {
62        let params: Option<&[(&str, &str)]> = if let Some(sk) = self.last_knowledge_of_server {
63            Some(&[("last_knowledge_of_server", &sk.to_string())])
64        } else {
65            None
66        };
67        let result: MonthsDataEnvelope = self
68            .client
69            .get(&format!("plans/{}/months", self.plan_id), params)
70            .await?;
71        Ok((result.data.months, result.data.server_knowledge))
72    }
73}
74
75impl Client {
76    /// Returns all plan months. The second return value is server knowledge for delta requests.
77    pub fn get_months(&self, plan_id: PlanId) -> GetMonthsBuilder<'_> {
78        GetMonthsBuilder {
79            client: self,
80            plan_id,
81            last_knowledge_of_server: None,
82        }
83    }
84
85    /// Returns a single plan month.
86    pub async fn get_month(&self, plan_id: PlanId, month: NaiveDate) -> Result<Month, Error> {
87        let result: MonthDataEnvelope = self
88            .get(&format!("plans/{}/months/{}", plan_id, month), NO_PARAMS)
89            .await?;
90        Ok(result.data.month)
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use crate::ynab::testutil::{TEST_ID_1, month_fixture, new_test_client};
98    use serde_json::json;
99    use uuid::uuid;
100    use wiremock::matchers::{method, path};
101    use wiremock::{Mock, ResponseTemplate};
102
103    fn months_list_fixture() -> serde_json::Value {
104        json!({ "data": { "months": [month_fixture()], "server_knowledge": 6 } })
105    }
106
107    fn month_single_fixture() -> serde_json::Value {
108        json!({ "data": { "month": month_fixture() } })
109    }
110
111    #[tokio::test]
112    async fn get_months_returns_months() {
113        let (client, server) = new_test_client().await;
114        Mock::given(method("GET"))
115            .and(path(format!("/plans/{}/months", TEST_ID_1)))
116            .respond_with(ResponseTemplate::new(200).set_body_json(months_list_fixture()))
117            .expect(1)
118            .mount(&server)
119            .await;
120        let (months, sk) = client
121            .get_months(PlanId::Id(uuid!(TEST_ID_1)))
122            .send()
123            .await
124            .unwrap();
125        assert_eq!(months.len(), 1);
126        assert_eq!(months[0].income, 500000);
127        assert_eq!(sk, 6);
128    }
129
130    #[tokio::test]
131    async fn get_month_returns_month() {
132        let (client, server) = new_test_client().await;
133        let month = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
134        Mock::given(method("GET"))
135            .and(path(format!("/plans/{}/months/{}", TEST_ID_1, month)))
136            .respond_with(ResponseTemplate::new(200).set_body_json(month_single_fixture()))
137            .expect(1)
138            .mount(&server)
139            .await;
140        let m = client
141            .get_month(PlanId::Id(uuid!(TEST_ID_1)), month)
142            .await
143            .unwrap();
144        assert_eq!(m.income, 500000);
145        assert_eq!(m.categories.len(), 1);
146    }
147
148    #[test]
149    fn deserializes_without_optional_fields() {
150        let json = r#"{ "month": "2024-01-01", "note": null, "income": 0,
151              "budgeted": 0, "activity": 0, "to_be_budgeted": 0,
152              "age_of_money": null, "deleted": false }"#;
153        let month: Month = serde_json::from_str(json).unwrap();
154        assert!(month.categories.is_empty());
155    }
156}