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, 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, 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, Serialize, Deserialize)]
87pub enum GoalType {
88 #[serde(rename = "TB")]
89 TargetBalance, #[serde(rename = "TBD")]
91 TargetBalanceByDate, #[serde(rename = "NEED")]
93 PlanYourSpending, #[serde(rename = "MF")]
95 MonthlyFunding, #[serde(rename = "DEBT")]
97 Debt, #[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 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 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 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#[derive(Debug, Serialize)]
175pub struct SaveCategoryGroup {
176 pub name: String,
177}
178
179#[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#[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#[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 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 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 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 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 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}