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#[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 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 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}