Skip to main content

rust_ynab/ynab/
category.rs

1use chrono::{DateTime, NaiveDate};
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, Serialize, Deserialize)]
11struct CategoriesDataEnvelope {
12    data: CategoriesData,
13}
14
15#[derive(Debug, Serialize, Deserialize)]
16struct CategoriesData {
17    category_groups: Vec<CategoryGroup>,
18    server_knowledge: i64,
19}
20
21#[derive(Debug, Serialize, Deserialize)]
22struct CategoryDataEnvelope {
23    data: CategoryData,
24}
25
26#[derive(Debug, Serialize, Deserialize)]
27struct CategoryData {
28    category: Category,
29}
30
31#[derive(Debug, Serialize, Deserialize)]
32struct SaveCategoryGroupDataEnvelope {
33    data: CategoryGroupData,
34}
35
36#[derive(Debug, Serialize, Deserialize)]
37struct CategoryGroupData {
38    category_group: CategoryGroup,
39    server_knowledge: i64,
40}
41
42/// A group of budget categories.
43#[derive(Debug, Serialize, Deserialize)]
44pub struct CategoryGroup {
45    pub id: Uuid,
46    pub name: String,
47    pub hidden: bool,
48    pub deleted: bool,
49    #[serde(default)]
50    pub categories: Vec<Category>,
51}
52
53/// A budget category. Amounts (assigned, activity, available, etc.) are specific to the current
54/// plan month (UTC) and are in milliunits (divide by 1000 for display).
55#[derive(Debug, Serialize, Deserialize)]
56pub struct Category {
57    pub id: Uuid,
58    pub category_group_id: Uuid,
59    pub category_group_name: Option<String>,
60    pub name: String,
61    pub hidden: bool,
62    pub original_category_group_id: Option<Uuid>,
63    pub note: Option<String>,
64    pub budgeted: i64,
65    pub activity: i64,
66    pub balance: i64,
67    pub goal_type: Option<GoalType>,
68    pub goal_needs_whole_amount: Option<bool>,
69    pub goal_day: Option<usize>,
70    pub goal_cadence: Option<usize>,
71    pub goal_cadence_frequency: Option<usize>,
72    pub goal_creation_month: Option<NaiveDate>,
73    pub goal_target: Option<i64>,
74    pub goal_target_date: Option<NaiveDate>,
75    pub goal_target_month: Option<NaiveDate>,
76    pub goal_percentage_complete: Option<usize>,
77    pub goal_months_to_budget: Option<usize>,
78    pub goal_under_funded: Option<i64>,
79    pub goal_overall_funded: Option<i64>,
80    pub goal_overall_left: Option<i64>,
81    pub goal_snoozed_at: Option<DateTime<chrono::Utc>>,
82    pub deleted: bool,
83}
84
85/// The type of savings or spending goal assigned to a category.
86#[derive(Debug, Serialize, Deserialize)]
87pub enum GoalType {
88    #[serde(rename = "TB")]
89    TargetBalance, // "TB"
90    #[serde(rename = "TBD")]
91    TargetBalanceByDate, // "TBD"
92    #[serde(rename = "NEED")]
93    PlanYourSpending, // "NEED"
94    #[serde(rename = "MF")]
95    MonthlyFunding, // "MF"
96    #[serde(rename = "DEBT")]
97    Debt, // "DEBT"
98    #[serde(other)]
99    Other,
100}
101
102#[derive(Debug)]
103pub struct GetCategoriesBuilder<'a> {
104    client: &'a Client,
105    plan_id: PlanId,
106    last_knowledge_of_server: Option<i64>,
107}
108
109impl<'a> GetCategoriesBuilder<'a> {
110    pub fn with_server_knowledge(mut self, sk: i64) -> GetCategoriesBuilder<'a> {
111        self.last_knowledge_of_server = Some(sk);
112        self
113    }
114
115    pub async fn send(self) -> Result<(Vec<CategoryGroup>, i64), Error> {
116        let params: Option<&[(&str, &str)]> = if let Some(sk) = self.last_knowledge_of_server {
117            Some(&[("last_knowledge_of_server", &sk.to_string())])
118        } else {
119            None
120        };
121        let result: CategoriesDataEnvelope = self
122            .client
123            .get(&format!("plans/{}/categories", self.plan_id), params)
124            .await?;
125        Ok((result.data.category_groups, result.data.server_knowledge))
126    }
127}
128
129impl Client {
130    /// Returns all categories grouped by category group. Amounts (assigned, activity, available,
131    /// etc.) are specific to the current plan month (UTC). The second return value is server
132    /// knowledge for delta requests.
133    pub fn get_categories(&self, plan_id: PlanId) -> GetCategoriesBuilder<'_> {
134        GetCategoriesBuilder {
135            client: self,
136            plan_id,
137            last_knowledge_of_server: None,
138        }
139    }
140
141    /// Returns a single category. Amounts (assigned, activity, available, etc.) are specific to
142    /// the current plan month (UTC).
143    pub async fn get_category(&self, plan_id: PlanId, cat_id: Uuid) -> Result<Category, Error> {
144        let result: CategoryDataEnvelope = self
145            .get(
146                &format!("plans/{}/categories/{}", plan_id, cat_id),
147                NO_PARAMS,
148            )
149            .await?;
150
151        Ok(result.data.category)
152    }
153
154    /// Returns a single category for a specific plan month. Amounts (assigned, activity,
155    /// available, etc.) are specific to the current plan month (UTC).
156    pub async fn get_category_for_month(
157        &self,
158        plan_id: PlanId,
159        month: NaiveDate,
160        cat_id: Uuid,
161    ) -> Result<Category, Error> {
162        let result: CategoryDataEnvelope = self
163            .get(
164                &format!("plans/{}/months/{}/categories/{}", plan_id, month, cat_id),
165                NO_PARAMS,
166            )
167            .await?;
168
169        Ok(result.data.category)
170    }
171}
172
173/// The category group to create or update.
174#[derive(Debug, Serialize)]
175pub struct SaveCategoryGroup {
176    pub name: String,
177}
178
179/// The category to create.
180#[derive(Debug, Serialize)]
181pub struct NewCategory {
182    pub name: String,
183    pub category_group_id: Uuid,
184    pub note: Option<String>,
185    pub goal_target: Option<i64>,
186    pub goal_target_date: Option<NaiveDate>,
187    pub goal_needs_whole_amount: Option<bool>,
188}
189
190/// The category to update. Only specified (non-`None`) fields will be changed.
191#[derive(Debug, Serialize)]
192pub struct SaveCategory {
193    #[serde(skip_serializing_if = "Option::is_none")]
194    pub name: Option<String>,
195    #[serde(skip_serializing_if = "Option::is_none")]
196    pub category_group_id: Option<Uuid>,
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub note: Option<String>,
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub goal_target: Option<i64>,
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub goal_target_date: Option<NaiveDate>,
203    #[serde(skip_serializing_if = "Option::is_none")]
204    pub goal_needs_whole_amount: Option<bool>,
205}
206
207/// The month category to update. Only `budgeted` (assigned) can be changed.
208#[derive(Debug, Serialize)]
209pub struct SaveMonthCategory {
210    pub budgeted: i64,
211}
212
213#[derive(Debug, Serialize)]
214struct NewCategoryBody {
215    category: NewCategory,
216}
217
218#[derive(Debug, Serialize)]
219struct SaveCategoryBody {
220    category: SaveCategory,
221}
222
223#[derive(Debug, Serialize)]
224struct SaveMonthCategoryBody {
225    category: SaveMonthCategory,
226}
227
228#[derive(Debug, Serialize)]
229struct SaveCategoryGroupBody {
230    category_group: SaveCategoryGroup,
231}
232
233#[derive(Debug, Serialize, Deserialize)]
234struct SaveCategoryDataEnvelope {
235    data: SaveCategoryData,
236}
237
238#[derive(Debug, Serialize, Deserialize)]
239struct SaveCategoryData {
240    category: Category,
241    server_knowledge: i64,
242}
243
244impl Client {
245    /// Creates a new category.
246    pub async fn create_category(
247        &self,
248        plan_id: PlanId,
249        category: NewCategory,
250    ) -> Result<(Category, i64), Error> {
251        let result: SaveCategoryDataEnvelope = self
252            .post(
253                &format!("plans/{plan_id}/categories"),
254                NewCategoryBody { category },
255            )
256            .await?;
257        Ok((result.data.category, result.data.server_knowledge))
258    }
259
260    /// Creates a new category group.
261    pub async fn create_category_group(
262        &self,
263        plan_id: PlanId,
264        category_group: SaveCategoryGroup,
265    ) -> Result<(CategoryGroup, i64), Error> {
266        let result: SaveCategoryGroupDataEnvelope = self
267            .post(
268                &format!("plans/{plan_id}/category_groups"),
269                SaveCategoryGroupBody { category_group },
270            )
271            .await?;
272        Ok((result.data.category_group, result.data.server_knowledge))
273    }
274
275    /// Update a category.
276    pub async fn update_category(
277        &self,
278        plan_id: PlanId,
279        category_id: Uuid,
280        category: SaveCategory,
281    ) -> Result<(Category, i64), Error> {
282        let result: SaveCategoryDataEnvelope = self
283            .patch(
284                &format!("plans/{plan_id}/categories/{category_id}"),
285                SaveCategoryBody { category },
286            )
287            .await?;
288        Ok((result.data.category, result.data.server_knowledge))
289    }
290
291    /// Update a category for a specific month. Only `budgeted` (assigned) amount can be updated.`
292    pub async fn update_category_for_month(
293        &self,
294        plan_id: PlanId,
295        month: NaiveDate,
296        category_id: Uuid,
297        category: SaveMonthCategory,
298    ) -> Result<(Category, i64), Error> {
299        let result: SaveCategoryDataEnvelope = self
300            .patch(
301                &format!("plans/{plan_id}/months/{month}/categories/{category_id}"),
302                SaveMonthCategoryBody { category },
303            )
304            .await?;
305        Ok((result.data.category, result.data.server_knowledge))
306    }
307
308    /// Update a category group.
309    pub async fn update_category_group(
310        &self,
311        plan_id: PlanId,
312        category_group_id: Uuid,
313        category_group: SaveCategoryGroup,
314    ) -> Result<(CategoryGroup, i64), Error> {
315        let result: SaveCategoryGroupDataEnvelope = self
316            .patch(
317                &format!("plans/{plan_id}/category_groups/{category_group_id}"),
318                SaveCategoryGroupBody { category_group },
319            )
320            .await?;
321        Ok((result.data.category_group, result.data.server_knowledge))
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328    use crate::ynab::testutil::{
329        TEST_ID_1, TEST_ID_2, category_fixture, category_group_fixture, error_body, new_test_client,
330    };
331    use serde_json::json;
332    use wiremock::matchers::{method, path};
333    use wiremock::{Mock, ResponseTemplate};
334
335    #[tokio::test]
336    async fn create_category_succeeds() {
337        let (client, server) = new_test_client().await;
338
339        let fixture = category_fixture();
340        let envelope = json!({
341            "data": {
342                "category": fixture,
343                "server_knowledge": 1
344            }
345        });
346
347        Mock::given(method("POST"))
348            .and(path(format!("/plans/{}/categories", TEST_ID_1)))
349            .respond_with(ResponseTemplate::new(201).set_body_json(envelope))
350            .expect(1)
351            .mount(&server)
352            .await;
353
354        let category = NewCategory {
355            name: fixture["name"].as_str().unwrap().to_string(),
356            category_group_id: TEST_ID_2.parse().unwrap(),
357            note: None,
358            goal_target: None,
359            goal_target_date: None,
360            goal_needs_whole_amount: None,
361        };
362
363        let (response, sk) = client
364            .create_category(PlanId::Id(TEST_ID_1.parse().unwrap()), category)
365            .await
366            .unwrap();
367
368        assert_eq!(response.id.to_string(), TEST_ID_1);
369        assert_eq!(response.name, fixture["name"].as_str().unwrap());
370        assert_eq!(response.balance, fixture["balance"].as_i64().unwrap());
371        assert_eq!(sk, 1);
372    }
373
374    #[tokio::test]
375    async fn create_category_returns_internal_server_error() {
376        let (client, server) = new_test_client().await;
377
378        Mock::given(method("POST"))
379            .and(path(format!("/plans/{}/categories", TEST_ID_1)))
380            .respond_with(ResponseTemplate::new(500).set_body_json(error_body(
381                "500",
382                "internal_server_error",
383                "An internal error occurred",
384            )))
385            .expect(1)
386            .mount(&server)
387            .await;
388
389        let category = NewCategory {
390            name: "Groceries".to_string(),
391            category_group_id: TEST_ID_2.parse().unwrap(),
392            note: None,
393            goal_target: None,
394            goal_target_date: None,
395            goal_needs_whole_amount: None,
396        };
397
398        let result = client
399            .create_category(PlanId::Id(TEST_ID_1.parse().unwrap()), category)
400            .await;
401
402        assert!(matches!(result, Err(Error::InternalServerError(_))));
403    }
404
405    #[tokio::test]
406    async fn get_categories_returns_category_groups() {
407        let (client, server) = new_test_client().await;
408        let fixture = json!({
409            "data": { "category_groups": [category_group_fixture()], "server_knowledge": 2 }
410        });
411        Mock::given(method("GET"))
412            .and(path(format!("/plans/{}/categories", TEST_ID_1)))
413            .respond_with(ResponseTemplate::new(200).set_body_json(fixture))
414            .expect(1)
415            .mount(&server)
416            .await;
417        let (groups, sk) = client
418            .get_categories(PlanId::Id(TEST_ID_1.parse().unwrap()))
419            .send()
420            .await
421            .unwrap();
422        assert_eq!(groups.len(), 1);
423        assert_eq!(groups[0].id.to_string(), TEST_ID_2);
424        assert_eq!(groups[0].categories.len(), 1);
425        assert_eq!(sk, 2);
426    }
427
428    #[tokio::test]
429    async fn get_category_returns_category() {
430        let (client, server) = new_test_client().await;
431        let fixture = category_fixture();
432        let envelope = json!({ "data": { "category": fixture } });
433        Mock::given(method("GET"))
434            .and(path(format!(
435                "/plans/{}/categories/{}",
436                TEST_ID_1, TEST_ID_1
437            )))
438            .respond_with(ResponseTemplate::new(200).set_body_json(envelope))
439            .expect(1)
440            .mount(&server)
441            .await;
442        let category = client
443            .get_category(
444                PlanId::Id(TEST_ID_1.parse().unwrap()),
445                TEST_ID_1.parse().unwrap(),
446            )
447            .await
448            .unwrap();
449        assert_eq!(category.id.to_string(), TEST_ID_1);
450        assert_eq!(category.name, "Groceries");
451    }
452
453    #[tokio::test]
454    async fn get_category_for_month_returns_category() {
455        let (client, server) = new_test_client().await;
456        let month = chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
457        let fixture = category_fixture();
458        let envelope = json!({ "data": { "category": fixture } });
459        Mock::given(method("GET"))
460            .and(path(format!(
461                "/plans/{}/months/{}/categories/{}",
462                TEST_ID_1, month, TEST_ID_1
463            )))
464            .respond_with(ResponseTemplate::new(200).set_body_json(envelope))
465            .expect(1)
466            .mount(&server)
467            .await;
468        let category = client
469            .get_category_for_month(
470                PlanId::Id(TEST_ID_1.parse().unwrap()),
471                month,
472                TEST_ID_1.parse().unwrap(),
473            )
474            .await
475            .unwrap();
476        assert_eq!(category.id.to_string(), TEST_ID_1);
477    }
478
479    #[tokio::test]
480    async fn create_category_group_succeeds() {
481        let (client, server) = new_test_client().await;
482        let fixture = category_group_fixture();
483        let envelope = json!({ "data": { "category_group": fixture, "server_knowledge": 2 } });
484        Mock::given(method("POST"))
485            .and(path(format!("/plans/{}/category_groups", TEST_ID_1)))
486            .respond_with(ResponseTemplate::new(201).set_body_json(envelope))
487            .expect(1)
488            .mount(&server)
489            .await;
490        let (group, sk) = client
491            .create_category_group(
492                PlanId::Id(TEST_ID_1.parse().unwrap()),
493                SaveCategoryGroup {
494                    name: "Everyday Expenses".to_string(),
495                },
496            )
497            .await
498            .unwrap();
499        assert_eq!(group.id.to_string(), TEST_ID_2);
500        assert_eq!(sk, 2);
501    }
502
503    #[tokio::test]
504    async fn update_category_succeeds() {
505        let (client, server) = new_test_client().await;
506        let fixture = category_fixture();
507        let envelope = json!({ "data": { "category": fixture, "server_knowledge": 4 } });
508        Mock::given(method("PATCH"))
509            .and(path(format!(
510                "/plans/{}/categories/{}",
511                TEST_ID_1, TEST_ID_1
512            )))
513            .respond_with(ResponseTemplate::new(200).set_body_json(envelope))
514            .expect(1)
515            .mount(&server)
516            .await;
517        let (category, sk) = client
518            .update_category(
519                PlanId::Id(TEST_ID_1.parse().unwrap()),
520                TEST_ID_1.parse().unwrap(),
521                SaveCategory {
522                    name: Some("Groceries".to_string()),
523                    category_group_id: None,
524                    note: None,
525                    goal_target: None,
526                    goal_target_date: None,
527                    goal_needs_whole_amount: None,
528                },
529            )
530            .await
531            .unwrap();
532        assert_eq!(category.id.to_string(), TEST_ID_1);
533        assert_eq!(sk, 4);
534    }
535
536    #[tokio::test]
537    async fn update_category_group_succeeds() {
538        let (client, server) = new_test_client().await;
539        let fixture = category_group_fixture();
540        let envelope = json!({ "data": { "category_group": fixture, "server_knowledge": 4 } });
541        Mock::given(method("PATCH"))
542            .and(path(format!(
543                "/plans/{}/category_groups/{}",
544                TEST_ID_1, TEST_ID_2
545            )))
546            .respond_with(ResponseTemplate::new(200).set_body_json(envelope))
547            .expect(1)
548            .mount(&server)
549            .await;
550        let (group, sk) = client
551            .update_category_group(
552                PlanId::Id(TEST_ID_1.parse().unwrap()),
553                TEST_ID_2.parse().unwrap(),
554                SaveCategoryGroup {
555                    name: "Everyday Expenses".to_string(),
556                },
557            )
558            .await
559            .unwrap();
560        assert_eq!(group.id.to_string(), TEST_ID_2);
561        assert_eq!(sk, 4);
562    }
563}