Skip to main content

rust_ynab/ynab/
transaction.rs

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