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