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    /// Sends the request. Returns months and server knowledge for use in subsequent delta requests.
62    pub async fn send(self) -> Result<(Vec<Month>, i64), Error> {
63        let params: Option<&[(&str, &str)]> = if let Some(sk) = self.last_knowledge_of_server {
64            Some(&[("last_knowledge_of_server", &sk.to_string())])
65        } else {
66            None
67        };
68        let result: MonthsDataEnvelope = self
69            .client
70            .get(&format!("plans/{}/months", self.plan_id), params)
71            .await?;
72        Ok((result.data.months, result.data.server_knowledge))
73    }
74}
75
76impl Client {
77    /// Returns a builder for fetching all plan months. Chain `.with_server_knowledge()` for a delta request.
78    pub fn get_months(&self, plan_id: PlanId) -> GetMonthsBuilder<'_> {
79        GetMonthsBuilder {
80            client: self,
81            plan_id,
82            last_knowledge_of_server: None,
83        }
84    }
85
86    /// Returns a single plan month.
87    pub async fn get_month(&self, plan_id: PlanId, month: NaiveDate) -> Result<Month, Error> {
88        let result: MonthDataEnvelope = self
89            .get(&format!("plans/{}/months/{}", plan_id, month), NO_PARAMS)
90            .await?;
91        Ok(result.data.month)
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use crate::ynab::testutil::{TEST_ID_1, month_fixture, new_test_client};
99    use serde_json::json;
100    use uuid::uuid;
101    use wiremock::matchers::{method, path};
102    use wiremock::{Mock, ResponseTemplate};
103
104    fn months_list_fixture() -> serde_json::Value {
105        json!({ "data": { "months": [month_fixture()], "server_knowledge": 6 } })
106    }
107
108    fn month_single_fixture() -> serde_json::Value {
109        json!({ "data": { "month": month_fixture() } })
110    }
111
112    #[tokio::test]
113    async fn get_months_returns_months() {
114        let (client, server) = new_test_client().await;
115        Mock::given(method("GET"))
116            .and(path(format!("/plans/{}/months", TEST_ID_1)))
117            .respond_with(ResponseTemplate::new(200).set_body_json(months_list_fixture()))
118            .expect(1)
119            .mount(&server)
120            .await;
121        let (months, sk) = client
122            .get_months(PlanId::Id(uuid!(TEST_ID_1)))
123            .send()
124            .await
125            .unwrap();
126        assert_eq!(months.len(), 1);
127        assert_eq!(months[0].income, 500000);
128        assert_eq!(sk, 6);
129    }
130
131    #[tokio::test]
132    async fn get_month_returns_month() {
133        let (client, server) = new_test_client().await;
134        let month = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
135        Mock::given(method("GET"))
136            .and(path(format!("/plans/{}/months/{}", TEST_ID_1, month)))
137            .respond_with(ResponseTemplate::new(200).set_body_json(month_single_fixture()))
138            .expect(1)
139            .mount(&server)
140            .await;
141        let m = client
142            .get_month(PlanId::Id(uuid!(TEST_ID_1)), month)
143            .await
144            .unwrap();
145        assert_eq!(m.income, 500000);
146        assert_eq!(m.categories.len(), 1);
147    }
148
149    #[test]
150    fn deserializes_without_optional_fields() {
151        let json = r#"{ "month": "2024-01-01", "note": null, "income": 0,
152              "budgeted": 0, "activity": 0, "to_be_budgeted": 0,
153              "age_of_money": null, "deleted": false }"#;
154        let month: Month = serde_json::from_str(json).unwrap();
155        assert!(month.categories.is_empty());
156    }
157}