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#[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#[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#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
87#[non_exhaustive]
88pub enum GoalType {
89 #[serde(rename = "TB")]
90 TargetBalance, #[serde(rename = "TBD")]
92 TargetBalanceByDate, #[serde(rename = "NEED")]
94 PlanYourSpending, #[serde(rename = "MF")]
96 MonthlyFunding, #[serde(rename = "DEBT")]
98 Debt, #[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 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
176pub struct SaveCategoryGroup {
177 pub name: String,
178}
179
180#[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#[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#[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 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 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 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 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 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}