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> {
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 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 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}