1use chrono::NaiveDate;
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5use crate::PlanId;
6use crate::ynab::client::Client;
7use crate::ynab::common::NO_PARAMS;
8use crate::ynab::errors::Error;
9
10#[derive(Debug, Deserialize)]
13struct TransactionDataEnvelope {
14 data: TransactionData,
15}
16
17#[derive(Debug, Deserialize)]
18struct TransactionData {
19 transaction: Transaction,
20 server_knowledge: i64,
21}
22
23#[derive(Debug, Deserialize)]
24struct TransactionsDataEnvelope {
25 data: TransactionsData,
26}
27
28#[derive(Debug, Deserialize)]
29struct TransactionsData {
30 transactions: Vec<Transaction>,
31 server_knowledge: i64,
32}
33
34#[derive(Debug, Deserialize)]
35struct ScheduledTransactionDataEnvelope {
36 data: ScheduledTransactionData,
37}
38
39#[derive(Debug, Deserialize)]
40struct ScheduledTransactionData {
41 scheduled_transaction: ScheduledTransaction,
42}
43
44#[derive(Debug, Deserialize)]
45struct ScheduledTransactionsDataEnvelope {
46 data: ScheduledTransactionsData,
47}
48
49#[derive(Debug, Deserialize)]
50struct ScheduledTransactionsData {
51 scheduled_transactions: Vec<ScheduledTransaction>,
52 server_knowledge: i64,
53}
54
55#[derive(Debug, Deserialize)]
56struct SaveTransactionsDataEnvelope {
57 data: SaveTransactionsResponse,
58}
59
60#[derive(Debug, Clone, PartialEq, Deserialize)]
62pub struct SaveTransactionsResponse {
63 pub transaction_ids: Vec<Uuid>,
64 pub transaction: Option<Transaction>,
65 pub transactions: Option<Vec<Transaction>>,
66 pub duplicate_import_ids: Option<Vec<Uuid>>,
67 pub server_knowledge: i64,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
74#[serde(rename_all = "snake_case")]
75pub enum ClearedStatus {
76 Cleared,
77 Uncleared,
78 Reconciled,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
83#[serde(rename_all = "snake_case")]
84pub enum FlagColor {
85 Red,
86 Orange,
87 Yellow,
88 Green,
89 Blue,
90 Purple,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
95#[serde(rename_all = "camelCase")]
96pub enum Frequency {
97 Never,
98 Daily,
99 Weekly,
100 EveryOtherWeek,
101 TwiceAMonth,
102 Every4Weeks,
103 Monthly,
104 EveryOtherMonth,
105 Every3Months,
106 Every4Months,
107 TwiceAYear,
108 Yearly,
109 EveryOtherYear,
110}
111
112#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
119pub struct Transaction {
120 pub id: String,
121 pub account_name: String,
122 pub date: NaiveDate,
123 pub amount: i64,
124 pub memo: Option<String>,
125 pub cleared: ClearedStatus,
126 pub approved: bool,
127 pub flag_color: Option<FlagColor>,
128 pub flag_name: Option<String>,
129 pub account_id: Uuid,
130 pub payee_id: Option<Uuid>,
131 pub payee_name: Option<String>,
132 pub category_id: Option<Uuid>,
133 pub category_name: Option<String>,
134 pub matched_transaction_id: Option<String>,
135 pub import_id: Option<String>,
136 pub import_payee_name: Option<String>,
137 pub import_payee_name_original: Option<String>,
138 pub deleted: bool,
139 #[serde(default)]
140 pub subtransactions: Vec<Subtransaction>,
141}
142
143#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
145pub struct Subtransaction {
146 pub id: String,
147 pub transaction_id: String,
148 pub amount: i64,
149 pub memo: Option<String>,
150 pub payee_id: Option<Uuid>,
151 pub payee_name: Option<String>,
152 pub category_id: Option<Uuid>,
153 pub category_name: Option<String>,
154 pub transfer_account_id: Option<Uuid>,
155 pub transfer_transaction_id: Option<String>,
156}
157
158#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
160pub struct ScheduledTransaction {
161 pub id: Uuid,
162 pub date_first: NaiveDate,
163 pub date_next: NaiveDate,
164 pub frequency: Frequency,
165 pub amount: i64,
166 pub memo: Option<String>,
167 pub flag_color: Option<FlagColor>,
168 pub flag_name: Option<String>,
169 pub account_id: Uuid,
170 pub payee_id: Option<Uuid>,
171 pub category_id: Option<Uuid>,
172 pub account_name: String,
173 pub payee_name: Option<String>,
174 pub category_name: Option<String>,
175 pub subtransactions: Vec<ScheduledSubtransaction>,
176 pub transfer_account_id: Option<Uuid>,
177}
178
179#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
182pub struct ScheduledSubtransaction {
183 pub id: Uuid,
184 pub scheduled_transaction_id: Uuid,
185 pub amount: i64,
186 pub memo: Option<String>,
187 pub payee_id: Option<Uuid>,
188 pub payee_name: Option<String>,
189 pub category_id: Option<Uuid>,
190 pub category_name: Option<String>,
191 pub transfer_account_id: Option<Uuid>,
192 pub deleted: bool,
193}
194
195#[derive(Debug, Clone, PartialEq, Eq, Hash)]
196pub enum TransactionType {
197 Uncategorized,
198 Unapproved,
199}
200
201impl std::fmt::Display for TransactionType {
202 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
203 match self {
204 Self::Unapproved => write!(f, "unapproved"),
205 Self::Uncategorized => write!(f, "uncategorized"),
206 }
207 }
208}
209
210#[derive(Debug, Clone)]
211enum TransactionScope {
212 All,
213 ByAccount(Uuid),
214 ByCategory(Uuid),
215 ByPayee(Uuid),
216 ByMonth(NaiveDate),
217}
218#[derive(Debug, Clone)]
219pub struct GetTransactionsBuilder<'a> {
220 client: &'a Client,
221 scope: TransactionScope,
222 plan_id: PlanId,
223 since_date: Option<NaiveDate>,
224 transaction_type: Option<TransactionType>,
225 last_knowledge_of_server: Option<i64>,
226}
227
228impl<'a> GetTransactionsBuilder<'a> {
229 pub fn with_server_knowledge(mut self, sk: i64) -> Self {
230 self.last_knowledge_of_server = Some(sk);
231 self
232 }
233
234 pub fn since_date(mut self, since_date: NaiveDate) -> Self {
235 self.since_date = Some(since_date);
236 self
237 }
238
239 pub fn transaction_type(mut self, tx_type: TransactionType) -> Self {
240 self.transaction_type = Some(tx_type);
241 self
242 }
243
244 pub async fn send(self) -> Result<(Vec<Transaction>, i64), Error> {
246 let date_str = self.since_date.map(|d| d.to_string());
247 let type_str = self.transaction_type.map(|t| t.to_string());
248 let sk_str = self.last_knowledge_of_server.map(|sk| sk.to_string());
249
250 let mut params: Vec<(&str, &str)> = Vec::new();
251 if let Some(ref s) = date_str {
252 params.push(("since_date", s));
253 }
254 if let Some(ref t) = type_str {
255 params.push(("type", t));
256 }
257 if let Some(ref s) = sk_str {
258 params.push(("last_knowledge_of_server", s));
259 }
260 let url = match self.scope {
261 TransactionScope::All => format!("plans/{}/transactions", self.plan_id),
262 TransactionScope::ByAccount(id) => {
263 format!("plans/{}/accounts/{}/transactions", self.plan_id, id)
264 }
265 TransactionScope::ByCategory(id) => {
266 format!("plans/{}/categories/{}/transactions", self.plan_id, id)
267 }
268 TransactionScope::ByPayee(id) => {
269 format!("plans/{}/payees/{}/transactions", self.plan_id, id)
270 }
271 TransactionScope::ByMonth(month) => {
272 format!("plans/{}/months/{}/transactions", self.plan_id, month)
273 }
274 };
275 let result: TransactionsDataEnvelope = self.client.get(&url, Some(¶ms)).await?;
276 Ok((result.data.transactions, result.data.server_knowledge))
277 }
278}
279
280impl Client {
281 pub fn get_transactions(&self, plan_id: PlanId) -> GetTransactionsBuilder<'_> {
305 GetTransactionsBuilder {
306 client: self,
307 scope: TransactionScope::All,
308 plan_id,
309 since_date: None,
310 transaction_type: None,
311 last_knowledge_of_server: None,
312 }
313 }
314
315 pub async fn get_transaction(
317 &self,
318 plan_id: PlanId,
319 transaction_id: &str,
320 ) -> Result<(Transaction, i64), Error> {
321 let result: TransactionDataEnvelope = self
322 .get(
323 &format!("plans/{}/transactions/{}", plan_id, transaction_id),
324 NO_PARAMS,
325 )
326 .await?;
327 Ok((result.data.transaction, result.data.server_knowledge))
328 }
329
330 pub fn get_transactions_by_account(
332 &self,
333 plan_id: PlanId,
334 account_id: Uuid,
335 ) -> GetTransactionsBuilder<'_> {
336 GetTransactionsBuilder {
337 client: self,
338 scope: TransactionScope::ByAccount(account_id),
339 plan_id,
340 since_date: None,
341 transaction_type: None,
342 last_knowledge_of_server: None,
343 }
344 }
345
346 pub fn get_transactions_by_category(
348 &self,
349 plan_id: PlanId,
350 category_id: Uuid,
351 ) -> GetTransactionsBuilder<'_> {
352 GetTransactionsBuilder {
353 client: self,
354 scope: TransactionScope::ByCategory(category_id),
355 plan_id,
356 since_date: None,
357 transaction_type: None,
358 last_knowledge_of_server: None,
359 }
360 }
361
362 pub fn get_transactions_by_payee(
364 &self,
365 plan_id: PlanId,
366 payee_id: Uuid,
367 ) -> GetTransactionsBuilder<'_> {
368 GetTransactionsBuilder {
369 client: self,
370 scope: TransactionScope::ByPayee(payee_id),
371 plan_id,
372 since_date: None,
373 transaction_type: None,
374 last_knowledge_of_server: None,
375 }
376 }
377
378 pub fn get_transactions_by_month(
380 &self,
381 plan_id: PlanId,
382 month: NaiveDate,
383 ) -> GetTransactionsBuilder<'_> {
384 GetTransactionsBuilder {
385 client: self,
386 scope: TransactionScope::ByMonth(month),
387 plan_id,
388 since_date: None,
389 transaction_type: None,
390 last_knowledge_of_server: None,
391 }
392 }
393}
394
395#[derive(Debug, Clone)]
396pub struct GetScheduledTransactionsBuilder<'a> {
397 client: &'a Client,
398 plan_id: PlanId,
399 last_knowledge_of_server: Option<i64>,
400}
401
402impl<'a> GetScheduledTransactionsBuilder<'a> {
403 pub fn with_server_knowledge(mut self, sk: i64) -> Self {
404 self.last_knowledge_of_server = Some(sk);
405 self
406 }
407
408 pub async fn send(self) -> Result<(Vec<ScheduledTransaction>, i64), Error> {
410 let params: Option<&[(&str, &str)]> = if let Some(sk) = self.last_knowledge_of_server {
411 Some(&[("last_knowledge_of_server", &sk.to_string())])
412 } else {
413 None
414 };
415 let result: ScheduledTransactionsDataEnvelope = self
416 .client
417 .get(
418 &format!("plans/{}/scheduled_transactions", self.plan_id),
419 params,
420 )
421 .await?;
422 Ok((
423 result.data.scheduled_transactions,
424 result.data.server_knowledge,
425 ))
426 }
427}
428
429impl Client {
430 pub fn get_scheduled_transactions(
433 &self,
434 plan_id: PlanId,
435 ) -> GetScheduledTransactionsBuilder<'_> {
436 GetScheduledTransactionsBuilder {
437 client: self,
438 plan_id,
439 last_knowledge_of_server: None,
440 }
441 }
442
443 pub async fn get_scheduled_transaction(
445 &self,
446 plan_id: PlanId,
447 transaction_id: Uuid,
448 ) -> Result<ScheduledTransaction, Error> {
449 let result: ScheduledTransactionDataEnvelope = self
450 .get(
451 &format!(
452 "plans/{}/scheduled_transactions/{}",
453 plan_id, transaction_id
454 ),
455 NO_PARAMS,
456 )
457 .await?;
458 Ok(result.data.scheduled_transaction)
459 }
460}
461
462#[derive(Debug, Serialize, Deserialize)]
463struct ImportTransactionsDataEnvelope {
464 data: ImportTransactionsData,
465}
466
467#[derive(Debug, Serialize, Deserialize)]
468struct ImportTransactionsData {
469 transaction_ids: Vec<Uuid>,
470}
471
472#[derive(Debug, Default, Serialize)]
473struct Empty {}
474
475impl Client {
476 pub async fn delete_transaction(
478 &self,
479 plan_id: PlanId,
480 tx_id: &str,
481 ) -> Result<(Transaction, i64), Error> {
482 let result: TransactionDataEnvelope = self
483 .delete(&format!("plans/{}/transactions/{}", plan_id, tx_id))
484 .await?;
485 Ok((result.data.transaction, result.data.server_knowledge))
486 }
487
488 pub async fn import_transactions(&self, plan_id: PlanId) -> Result<Vec<Uuid>, Error> {
492 let result: ImportTransactionsDataEnvelope = self
493 .post(
494 &format!("plans/{}/transactions/import", plan_id),
495 Empty::default(),
496 )
497 .await?;
498 Ok(result.data.transaction_ids)
499 }
500}
501
502#[derive(Debug, Clone, PartialEq, Serialize)]
504pub struct SaveSubTransaction {
505 pub amount: i64,
506 #[serde(skip_serializing_if = "Option::is_none")]
507 pub payee_id: Option<Uuid>,
508 #[serde(skip_serializing_if = "Option::is_none")]
509 pub payee_name: Option<String>,
510 #[serde(skip_serializing_if = "Option::is_none")]
511 pub category_id: Option<Uuid>,
512 #[serde(skip_serializing_if = "Option::is_none")]
513 pub memo: Option<String>,
514}
515
516#[derive(Debug, Clone, PartialEq, Serialize)]
518pub struct NewTransaction {
519 pub account_id: Uuid,
520 pub date: NaiveDate,
521 #[serde(skip_serializing_if = "Option::is_none")]
522 pub amount: Option<i64>,
523 #[serde(skip_serializing_if = "Option::is_none")]
524 pub payee_id: Option<Uuid>,
525 #[serde(skip_serializing_if = "Option::is_none")]
526 pub payee_name: Option<String>,
527 #[serde(skip_serializing_if = "Option::is_none")]
528 pub category_id: Option<Uuid>,
529 #[serde(skip_serializing_if = "Option::is_none")]
530 pub memo: Option<String>,
531 #[serde(skip_serializing_if = "Option::is_none")]
532 pub cleared: Option<ClearedStatus>,
533 #[serde(skip_serializing_if = "Option::is_none")]
534 pub approved: Option<bool>,
535 #[serde(skip_serializing_if = "Option::is_none")]
536 pub flag_color: Option<FlagColor>,
537 #[serde(skip_serializing_if = "Option::is_none")]
538 pub import_id: Option<String>,
539 #[serde(skip_serializing_if = "Option::is_none")]
540 pub subtransactions: Option<Vec<SaveSubTransaction>>,
541}
542
543#[derive(Debug, Clone, PartialEq, Serialize)]
545pub struct ExistingTransaction {
546 #[serde(skip_serializing_if = "Option::is_none")]
547 pub account_id: Option<Uuid>,
548 #[serde(skip_serializing_if = "Option::is_none")]
549 pub date: Option<NaiveDate>,
550 #[serde(skip_serializing_if = "Option::is_none")]
551 pub amount: Option<i64>,
552 #[serde(skip_serializing_if = "Option::is_none")]
553 pub payee_id: Option<Uuid>,
554 #[serde(skip_serializing_if = "Option::is_none")]
555 pub payee_name: Option<String>,
556 #[serde(skip_serializing_if = "Option::is_none")]
557 pub category_id: Option<Uuid>,
558 #[serde(skip_serializing_if = "Option::is_none")]
559 pub memo: Option<String>,
560 #[serde(skip_serializing_if = "Option::is_none")]
561 pub cleared: Option<ClearedStatus>,
562 #[serde(skip_serializing_if = "Option::is_none")]
563 pub approved: Option<bool>,
564 #[serde(skip_serializing_if = "Option::is_none")]
565 pub flag_color: Option<FlagColor>,
566 #[serde(skip_serializing_if = "Option::is_none")]
567 pub subtransactions: Option<Vec<SaveSubTransaction>>,
568}
569
570#[derive(Debug, Clone, PartialEq, Serialize)]
573pub struct SaveTransactionWithIdOrImportId {
574 #[serde(skip_serializing_if = "Option::is_none")]
575 pub id: Option<Uuid>,
576 #[serde(skip_serializing_if = "Option::is_none")]
577 pub import_id: Option<Uuid>,
578 #[serde(skip_serializing_if = "Option::is_none")]
579 pub account_id: Option<Uuid>,
580 #[serde(skip_serializing_if = "Option::is_none")]
581 pub date: Option<NaiveDate>,
582 #[serde(skip_serializing_if = "Option::is_none")]
583 pub amount: Option<i64>,
584 #[serde(skip_serializing_if = "Option::is_none")]
585 pub payee_id: Option<Uuid>,
586 #[serde(skip_serializing_if = "Option::is_none")]
587 pub payee_name: Option<String>,
588 #[serde(skip_serializing_if = "Option::is_none")]
589 pub category_id: Option<Uuid>,
590 #[serde(skip_serializing_if = "Option::is_none")]
591 pub memo: Option<String>,
592 #[serde(skip_serializing_if = "Option::is_none")]
593 pub cleared: Option<ClearedStatus>,
594 #[serde(skip_serializing_if = "Option::is_none")]
595 pub approved: Option<bool>,
596 #[serde(skip_serializing_if = "Option::is_none")]
597 pub flag_color: Option<FlagColor>,
598 #[serde(skip_serializing_if = "Option::is_none")]
599 pub subtransactions: Option<Vec<SaveSubTransaction>>,
600}
601
602#[derive(Debug, Serialize)]
603struct PostTransactionsWrapper {
604 transaction: Option<NewTransaction>,
605 transactions: Option<Vec<NewTransaction>>,
606}
607
608#[derive(Debug, Serialize)]
609struct PutTransactionWrapper {
610 transaction: ExistingTransaction,
611}
612
613#[derive(Debug, Serialize)]
614struct PatchTransactionsWrapper {
615 transactions: Vec<SaveTransactionWithIdOrImportId>,
616}
617
618impl Client {
619 pub async fn create_transaction(
647 &self,
648 plan_id: PlanId,
649 transaction: NewTransaction,
650 ) -> Result<SaveTransactionsResponse, Error> {
651 let result: SaveTransactionsDataEnvelope = self
652 .post(
653 &format!("plans/{}/transactions", plan_id),
654 PostTransactionsWrapper {
655 transaction: Some(transaction),
656 transactions: None,
657 },
658 )
659 .await?;
660 Ok(result.data)
661 }
662
663 pub async fn create_transactions(
665 &self,
666 plan_id: PlanId,
667 transactions: Vec<NewTransaction>,
668 ) -> Result<SaveTransactionsResponse, Error> {
669 let result: SaveTransactionsDataEnvelope = self
670 .post(
671 &format!("plans/{}/transactions", plan_id),
672 PostTransactionsWrapper {
673 transaction: None,
674 transactions: Some(transactions),
675 },
676 )
677 .await?;
678 Ok(result.data)
679 }
680
681 pub async fn update_transactions(
683 &self,
684 plan_id: PlanId,
685 transactions: Vec<SaveTransactionWithIdOrImportId>,
686 ) -> Result<SaveTransactionsResponse, Error> {
687 let result: SaveTransactionsDataEnvelope = self
688 .patch(
689 &format!("plans/{}/transactions", plan_id),
690 PatchTransactionsWrapper { transactions },
691 )
692 .await?;
693 Ok(result.data)
694 }
695
696 pub async fn update_transaction(
698 &self,
699 plan_id: PlanId,
700 tx_id: &str,
701 transaction: ExistingTransaction,
702 ) -> Result<(Transaction, i64), Error> {
703 let result: TransactionDataEnvelope = self
704 .put(
705 &format!("plans/{}/transactions/{}", plan_id, tx_id),
706 PutTransactionWrapper { transaction },
707 )
708 .await?;
709 Ok((result.data.transaction, result.data.server_knowledge))
710 }
711
712 pub async fn create_scheduled_transaction(
714 &self,
715 plan_id: PlanId,
716 scheduled_transaction: SaveScheduledTransaction,
717 ) -> Result<ScheduledTransaction, Error> {
718 let result: ScheduledTransactionDataEnvelope = self
719 .post(
720 &format!("plans/{}/scheduled_transactions", plan_id),
721 ScheduledTransactionWrapper {
722 scheduled_transaction,
723 },
724 )
725 .await?;
726 Ok(result.data.scheduled_transaction)
727 }
728
729 pub async fn update_scheduled_transaction(
731 &self,
732 plan_id: PlanId,
733 scheduled_transaction_id: Uuid,
734 scheduled_transaction: SaveScheduledTransaction,
735 ) -> Result<ScheduledTransaction, Error> {
736 let result: ScheduledTransactionDataEnvelope = self
737 .put(
738 &format!(
739 "plans/{}/scheduled_transactions/{}",
740 plan_id, scheduled_transaction_id
741 ),
742 ScheduledTransactionWrapper {
743 scheduled_transaction,
744 },
745 )
746 .await?;
747 Ok(result.data.scheduled_transaction)
748 }
749
750 pub async fn delete_scheduled_transaction(
752 &self,
753 plan_id: PlanId,
754 scheduled_transaction_id: Uuid,
755 ) -> Result<ScheduledTransaction, Error> {
756 let result: ScheduledTransactionDataEnvelope = self
757 .delete(&format!(
758 "plans/{}/scheduled_transactions/{}",
759 plan_id, scheduled_transaction_id
760 ))
761 .await?;
762 Ok(result.data.scheduled_transaction)
763 }
764}
765
766#[derive(Debug, Clone, PartialEq, Serialize)]
768pub struct SaveScheduledTransaction {
769 pub account_id: Uuid,
770 pub date: NaiveDate,
771 #[serde(skip_serializing_if = "Option::is_none")]
772 pub amount: Option<i64>,
773 #[serde(skip_serializing_if = "Option::is_none")]
774 pub payee_id: Option<Uuid>,
775 #[serde(skip_serializing_if = "Option::is_none")]
776 pub payee_name: Option<String>,
777 #[serde(skip_serializing_if = "Option::is_none")]
778 pub category_id: Option<Uuid>,
779 #[serde(skip_serializing_if = "Option::is_none")]
780 pub memo: Option<String>,
781 #[serde(skip_serializing_if = "Option::is_none")]
782 pub flag_color: Option<FlagColor>,
783 #[serde(skip_serializing_if = "Option::is_none")]
784 pub frequency: Option<Frequency>,
785}
786
787#[derive(Debug, Serialize)]
788struct ScheduledTransactionWrapper {
789 scheduled_transaction: SaveScheduledTransaction,
790}
791
792#[cfg(test)]
793mod tests {
794 use super::*;
795 use crate::ynab::testutil::{
796 TEST_ID_1, TEST_ID_3, TEST_ID_4, error_body, new_test_client,
797 scheduled_transaction_fixture, transaction_fixture,
798 };
799 use serde_json::json;
800 use uuid::uuid;
801 use wiremock::matchers::{method, path};
802 use wiremock::{Mock, ResponseTemplate};
803
804 fn transactions_list_fixture() -> serde_json::Value {
805 json!({ "data": { "transactions": [transaction_fixture()], "server_knowledge": 10 } })
806 }
807
808 fn transaction_single_fixture() -> serde_json::Value {
809 json!({ "data": { "transaction": transaction_fixture(), "server_knowledge": 10 } })
810 }
811
812 fn save_transactions_fixture() -> serde_json::Value {
813 json!({
814 "data": {
815 "transaction_ids": [TEST_ID_1],
816 "transaction": transaction_fixture(),
817 "transactions": [transaction_fixture()],
818 "duplicate_import_ids": null,
819 "server_knowledge": 10
820 }
821 })
822 }
823
824 fn scheduled_transactions_list_fixture() -> serde_json::Value {
825 json!({
826 "data": {
827 "scheduled_transactions": [scheduled_transaction_fixture()],
828 "server_knowledge": 10
829 }
830 })
831 }
832
833 fn scheduled_transaction_single_fixture() -> serde_json::Value {
834 json!({ "data": { "scheduled_transaction": scheduled_transaction_fixture() } })
835 }
836
837 fn import_transactions_fixture() -> serde_json::Value {
838 json!({ "data": { "transaction_ids": [TEST_ID_1] } })
839 }
840
841 #[tokio::test]
842 async fn get_transactions_returns_transactions() {
843 let (client, server) = new_test_client().await;
844 Mock::given(method("GET"))
845 .and(path(format!("/plans/{}/transactions", TEST_ID_1)))
846 .respond_with(ResponseTemplate::new(200).set_body_json(transactions_list_fixture()))
847 .expect(1)
848 .mount(&server)
849 .await;
850 let (txs, sk) = client
851 .get_transactions(PlanId::Id(uuid!(TEST_ID_1)))
852 .send()
853 .await
854 .unwrap();
855 assert_eq!(txs.len(), 1);
856 assert_eq!(txs[0].id, TEST_ID_1);
857 assert_eq!(txs[0].amount, -50000);
858 assert_eq!(sk, 10);
859 }
860
861 #[tokio::test]
862 async fn get_transaction_returns_transaction() {
863 let (client, server) = new_test_client().await;
864 Mock::given(method("GET"))
865 .and(path(format!(
866 "/plans/{}/transactions/{}",
867 TEST_ID_1, TEST_ID_1
868 )))
869 .respond_with(ResponseTemplate::new(200).set_body_json(transaction_single_fixture()))
870 .expect(1)
871 .mount(&server)
872 .await;
873 let (tx, sk) = client
874 .get_transaction(PlanId::Id(uuid!(TEST_ID_1)), TEST_ID_1)
875 .await
876 .unwrap();
877 assert_eq!(tx.id, TEST_ID_1);
878 assert_eq!(tx.amount, -50000);
879 assert_eq!(sk, 10);
880 }
881
882 #[tokio::test]
883 async fn get_transactions_by_account_returns_transactions() {
884 let (client, server) = new_test_client().await;
885 Mock::given(method("GET"))
886 .and(path(format!(
887 "/plans/{}/accounts/{}/transactions",
888 TEST_ID_1, TEST_ID_1
889 )))
890 .respond_with(ResponseTemplate::new(200).set_body_json(transactions_list_fixture()))
891 .expect(1)
892 .mount(&server)
893 .await;
894 let (txs, _) = client
895 .get_transactions_by_account(PlanId::Id(uuid!(TEST_ID_1)), uuid!(TEST_ID_1))
896 .send()
897 .await
898 .unwrap();
899 assert_eq!(txs.len(), 1);
900 }
901
902 #[tokio::test]
903 async fn get_transactions_by_category_returns_transactions() {
904 let (client, server) = new_test_client().await;
905 Mock::given(method("GET"))
906 .and(path(format!(
907 "/plans/{}/categories/{}/transactions",
908 TEST_ID_1, TEST_ID_1
909 )))
910 .respond_with(ResponseTemplate::new(200).set_body_json(transactions_list_fixture()))
911 .expect(1)
912 .mount(&server)
913 .await;
914 let (txs, _) = client
915 .get_transactions_by_category(PlanId::Id(uuid!(TEST_ID_1)), uuid!(TEST_ID_1))
916 .send()
917 .await
918 .unwrap();
919 assert_eq!(txs.len(), 1);
920 }
921
922 #[tokio::test]
923 async fn get_transactions_by_payee_returns_transactions() {
924 let (client, server) = new_test_client().await;
925 Mock::given(method("GET"))
926 .and(path(format!(
927 "/plans/{}/payees/{}/transactions",
928 TEST_ID_1, TEST_ID_3
929 )))
930 .respond_with(ResponseTemplate::new(200).set_body_json(transactions_list_fixture()))
931 .expect(1)
932 .mount(&server)
933 .await;
934 let (txs, _) = client
935 .get_transactions_by_payee(PlanId::Id(uuid!(TEST_ID_1)), uuid!(TEST_ID_3))
936 .send()
937 .await
938 .unwrap();
939 assert_eq!(txs.len(), 1);
940 }
941
942 #[tokio::test]
943 async fn get_transactions_by_month_returns_transactions() {
944 let (client, server) = new_test_client().await;
945 let month = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
946 Mock::given(method("GET"))
947 .and(path(format!(
948 "/plans/{}/months/{}/transactions",
949 TEST_ID_1, month
950 )))
951 .respond_with(ResponseTemplate::new(200).set_body_json(transactions_list_fixture()))
952 .expect(1)
953 .mount(&server)
954 .await;
955 let (txs, _) = client
956 .get_transactions_by_month(PlanId::Id(uuid!(TEST_ID_1)), month)
957 .send()
958 .await
959 .unwrap();
960 assert_eq!(txs.len(), 1);
961 }
962
963 #[tokio::test]
964 async fn create_transaction_succeeds() {
965 let (client, server) = new_test_client().await;
966 Mock::given(method("POST"))
967 .and(path(format!("/plans/{}/transactions", TEST_ID_1)))
968 .respond_with(ResponseTemplate::new(201).set_body_json(save_transactions_fixture()))
969 .expect(1)
970 .mount(&server)
971 .await;
972 let resp = client
973 .create_transaction(
974 PlanId::Id(uuid!(TEST_ID_1)),
975 NewTransaction {
976 account_id: uuid!(TEST_ID_1),
977 date: NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
978 amount: Some(-50000),
979 memo: None,
980 cleared: Some(ClearedStatus::Cleared),
981 approved: Some(true),
982 payee_id: None,
983 payee_name: None,
984 category_id: None,
985 flag_color: None,
986 import_id: None,
987 subtransactions: None,
988 },
989 )
990 .await
991 .unwrap();
992 assert_eq!(resp.transaction_ids, vec![uuid!(TEST_ID_1)]);
993 assert_eq!(resp.transaction.unwrap().amount, -50000);
994 }
995
996 #[tokio::test]
997 async fn create_transactions_succeeds() {
998 let (client, server) = new_test_client().await;
999 Mock::given(method("POST"))
1000 .and(path(format!("/plans/{}/transactions", TEST_ID_1)))
1001 .respond_with(ResponseTemplate::new(201).set_body_json(save_transactions_fixture()))
1002 .expect(1)
1003 .mount(&server)
1004 .await;
1005 let resp = client
1006 .create_transactions(
1007 PlanId::Id(uuid!(TEST_ID_1)),
1008 vec![NewTransaction {
1009 account_id: uuid!(TEST_ID_1),
1010 date: NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1011 amount: Some(-50000),
1012 memo: None,
1013 cleared: Some(ClearedStatus::Cleared),
1014 approved: Some(true),
1015 payee_id: None,
1016 payee_name: None,
1017 category_id: None,
1018 flag_color: None,
1019 import_id: None,
1020 subtransactions: None,
1021 }],
1022 )
1023 .await
1024 .unwrap();
1025 assert_eq!(resp.transaction_ids, vec![uuid!(TEST_ID_1)]);
1026 }
1027
1028 #[tokio::test]
1029 async fn update_transaction_succeeds() {
1030 let (client, server) = new_test_client().await;
1031 Mock::given(method("PUT"))
1032 .and(path(format!(
1033 "/plans/{}/transactions/{}",
1034 TEST_ID_1, TEST_ID_1
1035 )))
1036 .respond_with(ResponseTemplate::new(200).set_body_json(transaction_single_fixture()))
1037 .expect(1)
1038 .mount(&server)
1039 .await;
1040 let (tx, sk) = client
1041 .update_transaction(
1042 PlanId::Id(uuid!(TEST_ID_1)),
1043 TEST_ID_1,
1044 ExistingTransaction {
1045 amount: Some(-50000),
1046 account_id: None,
1047 date: None,
1048 payee_id: None,
1049 payee_name: None,
1050 category_id: None,
1051 memo: None,
1052 cleared: None,
1053 approved: None,
1054 flag_color: None,
1055 subtransactions: None,
1056 },
1057 )
1058 .await
1059 .unwrap();
1060 assert_eq!(tx.id, TEST_ID_1);
1061 assert_eq!(sk, 10);
1062 }
1063
1064 #[tokio::test]
1065 async fn update_transactions_succeeds() {
1066 let (client, server) = new_test_client().await;
1067 Mock::given(method("PATCH"))
1068 .and(path(format!("/plans/{}/transactions", TEST_ID_1)))
1069 .respond_with(ResponseTemplate::new(200).set_body_json(save_transactions_fixture()))
1070 .expect(1)
1071 .mount(&server)
1072 .await;
1073 let resp = client
1074 .update_transactions(
1075 PlanId::Id(uuid!(TEST_ID_1)),
1076 vec![SaveTransactionWithIdOrImportId {
1077 id: Some(Uuid::from_bytes([0; 16])),
1078 memo: Some("updated".to_string()),
1079 import_id: None,
1080 account_id: None,
1081 date: None,
1082 amount: None,
1083 payee_id: None,
1084 payee_name: None,
1085 category_id: None,
1086 cleared: None,
1087 approved: None,
1088 flag_color: None,
1089 subtransactions: None,
1090 }],
1091 )
1092 .await
1093 .unwrap();
1094 assert_eq!(resp.transaction_ids, vec![uuid!(TEST_ID_1)]);
1095 }
1096
1097 #[tokio::test]
1098 async fn delete_transaction_succeeds() {
1099 let (client, server) = new_test_client().await;
1100 Mock::given(method("DELETE"))
1101 .and(path(format!(
1102 "/plans/{}/transactions/{}",
1103 TEST_ID_1, TEST_ID_1
1104 )))
1105 .respond_with(ResponseTemplate::new(200).set_body_json(transaction_single_fixture()))
1106 .expect(1)
1107 .mount(&server)
1108 .await;
1109 let (tx, sk) = client
1110 .delete_transaction(PlanId::Id(uuid!(TEST_ID_1)), TEST_ID_1)
1111 .await
1112 .unwrap();
1113 assert_eq!(tx.id, TEST_ID_1);
1114 assert_eq!(sk, 10);
1115 }
1116
1117 #[tokio::test]
1118 async fn import_transactions_returns_ids() {
1119 let (client, server) = new_test_client().await;
1120 Mock::given(method("POST"))
1121 .and(path(format!("/plans/{}/transactions/import", TEST_ID_1)))
1122 .respond_with(ResponseTemplate::new(200).set_body_json(import_transactions_fixture()))
1123 .expect(1)
1124 .mount(&server)
1125 .await;
1126 let ids = client
1127 .import_transactions(PlanId::Id(uuid!(TEST_ID_1)))
1128 .await
1129 .unwrap();
1130 assert_eq!(ids.len(), 1);
1131 assert_eq!(ids[0].to_string(), TEST_ID_1);
1132 }
1133
1134 #[tokio::test]
1135 async fn get_scheduled_transactions_returns_transactions() {
1136 let (client, server) = new_test_client().await;
1137 Mock::given(method("GET"))
1138 .and(path(format!("/plans/{}/scheduled_transactions", TEST_ID_1)))
1139 .respond_with(
1140 ResponseTemplate::new(200).set_body_json(scheduled_transactions_list_fixture()),
1141 )
1142 .expect(1)
1143 .mount(&server)
1144 .await;
1145 let (txs, sk) = client
1146 .get_scheduled_transactions(PlanId::Id(uuid!(TEST_ID_1)))
1147 .send()
1148 .await
1149 .unwrap();
1150 assert_eq!(txs.len(), 1);
1151 assert_eq!(txs[0].id.to_string(), TEST_ID_4);
1152 assert_eq!(sk, 10);
1153 }
1154
1155 #[tokio::test]
1156 async fn get_scheduled_transaction_returns_transaction() {
1157 let (client, server) = new_test_client().await;
1158 Mock::given(method("GET"))
1159 .and(path(format!(
1160 "/plans/{}/scheduled_transactions/{}",
1161 TEST_ID_1, TEST_ID_4
1162 )))
1163 .respond_with(
1164 ResponseTemplate::new(200).set_body_json(scheduled_transaction_single_fixture()),
1165 )
1166 .expect(1)
1167 .mount(&server)
1168 .await;
1169 let tx = client
1170 .get_scheduled_transaction(PlanId::Id(uuid!(TEST_ID_1)), uuid!(TEST_ID_4))
1171 .await
1172 .unwrap();
1173 assert_eq!(tx.id.to_string(), TEST_ID_4);
1174 assert!(matches!(tx.frequency, Frequency::Monthly));
1175 }
1176
1177 #[tokio::test]
1178 async fn create_scheduled_transaction_succeeds() {
1179 let (client, server) = new_test_client().await;
1180 Mock::given(method("POST"))
1181 .and(path(format!("/plans/{}/scheduled_transactions", TEST_ID_1)))
1182 .respond_with(
1183 ResponseTemplate::new(201).set_body_json(scheduled_transaction_single_fixture()),
1184 )
1185 .expect(1)
1186 .mount(&server)
1187 .await;
1188 let tx = client
1189 .create_scheduled_transaction(
1190 PlanId::Id(uuid!(TEST_ID_1)),
1191 SaveScheduledTransaction {
1192 account_id: uuid!(TEST_ID_1),
1193 date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1194 amount: Some(-50000),
1195 frequency: Some(Frequency::Monthly),
1196 memo: None,
1197 payee_id: None,
1198 payee_name: None,
1199 category_id: None,
1200 flag_color: None,
1201 },
1202 )
1203 .await
1204 .unwrap();
1205 assert_eq!(tx.id.to_string(), TEST_ID_4);
1206 assert_eq!(tx.amount, -50000);
1207 }
1208
1209 #[tokio::test]
1210 async fn update_scheduled_transaction_succeeds() {
1211 let (client, server) = new_test_client().await;
1212 Mock::given(method("PUT"))
1213 .and(path(format!(
1214 "/plans/{}/scheduled_transactions/{}",
1215 TEST_ID_1, TEST_ID_4
1216 )))
1217 .respond_with(
1218 ResponseTemplate::new(200).set_body_json(scheduled_transaction_single_fixture()),
1219 )
1220 .expect(1)
1221 .mount(&server)
1222 .await;
1223 let tx = client
1224 .update_scheduled_transaction(
1225 PlanId::Id(uuid!(TEST_ID_1)),
1226 uuid!(TEST_ID_4),
1227 SaveScheduledTransaction {
1228 account_id: uuid!(TEST_ID_1),
1229 date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1230 amount: Some(-50000),
1231 frequency: Some(Frequency::Monthly),
1232 memo: None,
1233 payee_id: None,
1234 payee_name: None,
1235 category_id: None,
1236 flag_color: None,
1237 },
1238 )
1239 .await
1240 .unwrap();
1241 assert_eq!(tx.id.to_string(), TEST_ID_4);
1242 }
1243
1244 #[tokio::test]
1245 async fn delete_scheduled_transaction_succeeds() {
1246 let (client, server) = new_test_client().await;
1247 Mock::given(method("DELETE"))
1248 .and(path(format!(
1249 "/plans/{}/scheduled_transactions/{}",
1250 TEST_ID_1, TEST_ID_4
1251 )))
1252 .respond_with(
1253 ResponseTemplate::new(200).set_body_json(scheduled_transaction_single_fixture()),
1254 )
1255 .expect(1)
1256 .mount(&server)
1257 .await;
1258 let tx = client
1259 .delete_scheduled_transaction(PlanId::Id(uuid!(TEST_ID_1)), uuid!(TEST_ID_4))
1260 .await
1261 .unwrap();
1262 assert_eq!(tx.id.to_string(), TEST_ID_4);
1263 }
1264
1265 #[tokio::test]
1266 async fn get_transaction_returns_not_found() {
1267 let (client, server) = new_test_client().await;
1268 Mock::given(method("GET"))
1269 .and(path(format!(
1270 "/plans/{}/transactions/{}",
1271 TEST_ID_1, TEST_ID_1
1272 )))
1273 .respond_with(ResponseTemplate::new(404).set_body_json(error_body(
1274 "404",
1275 "not_found",
1276 "Transaction not found",
1277 )))
1278 .mount(&server)
1279 .await;
1280 let err = client
1281 .get_transaction(PlanId::Id(uuid!(TEST_ID_1)), &TEST_ID_1)
1282 .await
1283 .unwrap_err();
1284 assert!(matches!(err, Error::NotFound(_)));
1285 }
1286}