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    /// Sends the request. Returns transactions and server knowledge for use in subsequent delta requests.
253    pub async fn send(self) -> Result<(Vec<Transaction>, i64), Error> {
254        let date_str = self.since_date.map(|d| d.to_string());
255        let type_str = self.transaction_type.map(|t| t.to_string());
256        let sk_str = self.last_knowledge_of_server.map(|sk| sk.to_string());
257
258        let mut params: Vec<(&str, &str)> = Vec::new();
259        if let Some(ref s) = date_str {
260            params.push(("since_date", s));
261        }
262        if let Some(ref t) = type_str {
263            params.push(("type", t));
264        }
265        if let Some(ref s) = sk_str {
266            params.push(("last_knowledge_of_server", s));
267        }
268        let url = match self.scope {
269            TransactionScope::All => format!("plans/{}/transactions", self.plan_id),
270            TransactionScope::ByAccount(id) => {
271                format!("plans/{}/accounts/{}/transactions", self.plan_id, id)
272            }
273            TransactionScope::ByCategory(id) => {
274                format!("plans/{}/categories/{}/transactions", self.plan_id, id)
275            }
276            TransactionScope::ByPayee(id) => {
277                format!("plans/{}/payees/{}/transactions", self.plan_id, id)
278            }
279            TransactionScope::ByMonth(month) => {
280                format!("plans/{}/months/{}/transactions", self.plan_id, month)
281            }
282        };
283        let result: TransactionsDataEnvelope = self.client.get(&url, Some(&params)).await?;
284        Ok((result.data.transactions, result.data.server_knowledge))
285    }
286}
287
288impl Client {
289    /// Returns a builder for fetching transactions. Chain `.with_server_knowledge()`,
290    /// `.since_date()`, or `.transaction_type()` before calling `.send()`.
291    ///
292    /// # Examples
293    ///
294    /// ```no_run
295    /// # use rust_ynab::{Client, PlanId};
296    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
297    /// # let client = Client::new(&std::env::var("YNAB_TOKEN")?)?;
298    /// // Full fetch
299    /// let (transactions, server_knowledge) = client
300    ///     .get_transactions(PlanId::LastUsed)
301    ///     .send()
302    ///     .await?;
303    ///
304    /// // Delta request — only changes since last sync
305    /// let (changes, new_sk) = client
306    ///     .get_transactions(PlanId::LastUsed)
307    ///     .with_server_knowledge(server_knowledge)
308    ///     .send()
309    ///     .await?;
310    /// # Ok(()) }
311    /// ```
312    pub fn get_transactions(&self, plan_id: PlanId) -> GetTransactionsBuilder<'_> {
313        GetTransactionsBuilder {
314            client: self,
315            scope: TransactionScope::All,
316            plan_id,
317            since_date: None,
318            transaction_type: None,
319            last_knowledge_of_server: None,
320        }
321    }
322
323    /// Returns a single transaction.
324    pub async fn get_transaction(
325        &self,
326        plan_id: PlanId,
327        transaction_id: &Uuid,
328    ) -> Result<(Transaction, i64), Error> {
329        let result: TransactionDataEnvelope = self
330            .get(
331                &format!("plans/{}/transactions/{}", plan_id, transaction_id),
332                NO_PARAMS,
333            )
334            .await?;
335        Ok((result.data.transaction, result.data.server_knowledge))
336    }
337
338    /// Returns a builder for fetching transactions for a specified account.
339    pub fn get_transactions_by_account(
340        &self,
341        plan_id: PlanId,
342        account_id: Uuid,
343    ) -> GetTransactionsBuilder<'_> {
344        GetTransactionsBuilder {
345            client: self,
346            scope: TransactionScope::ByAccount(account_id),
347            plan_id,
348            since_date: None,
349            transaction_type: None,
350            last_knowledge_of_server: None,
351        }
352    }
353
354    /// Returns a builder for fetching transactions for a specified category.
355    pub fn get_transactions_by_category(
356        &self,
357        plan_id: PlanId,
358        category_id: Uuid,
359    ) -> GetTransactionsBuilder<'_> {
360        GetTransactionsBuilder {
361            client: self,
362            scope: TransactionScope::ByCategory(category_id),
363            plan_id,
364            since_date: None,
365            transaction_type: None,
366            last_knowledge_of_server: None,
367        }
368    }
369
370    /// Returns a builder for fetching transactions for a specified payee.
371    pub fn get_transactions_by_payee(
372        &self,
373        plan_id: PlanId,
374        payee_id: Uuid,
375    ) -> GetTransactionsBuilder<'_> {
376        GetTransactionsBuilder {
377            client: self,
378            scope: TransactionScope::ByPayee(payee_id),
379            plan_id,
380            since_date: None,
381            transaction_type: None,
382            last_knowledge_of_server: None,
383        }
384    }
385
386    /// Returns a builder for fetching transactions for a specified month.
387    pub fn get_transactions_by_month(
388        &self,
389        plan_id: PlanId,
390        month: NaiveDate,
391    ) -> GetTransactionsBuilder<'_> {
392        GetTransactionsBuilder {
393            client: self,
394            scope: TransactionScope::ByMonth(month),
395            plan_id,
396            since_date: None,
397            transaction_type: None,
398            last_knowledge_of_server: None,
399        }
400    }
401}
402
403#[derive(Debug)]
404pub struct GetScheduledTransactionsBuilder<'a> {
405    client: &'a Client,
406    plan_id: PlanId,
407    last_knowledge_of_server: Option<i64>,
408}
409
410impl<'a> GetScheduledTransactionsBuilder<'a> {
411    pub fn with_server_knowledge(mut self, sk: i64) -> Self {
412        self.last_knowledge_of_server = Some(sk);
413        self
414    }
415
416    /// Sends the request. Returns scheduled transactions and server knowledge for use in subsequent delta requests.
417    pub async fn send(self) -> Result<(Vec<ScheduledTransaction>, i64), Error> {
418        let params: Option<&[(&str, &str)]> = if let Some(sk) = self.last_knowledge_of_server {
419            Some(&[("last_knowledge_of_server", &sk.to_string())])
420        } else {
421            None
422        };
423        let result: ScheduledTransactionsDataEnvelope = self
424            .client
425            .get(
426                &format!("plans/{}/scheduled_transactions", self.plan_id),
427                params,
428            )
429            .await?;
430        Ok((
431            result.data.scheduled_transactions,
432            result.data.server_knowledge,
433        ))
434    }
435}
436
437impl Client {
438    /// Returns a builder for fetching all scheduled transactions. Chain `.with_server_knowledge()`
439    /// for a delta request.
440    pub fn get_scheduled_transactions(
441        &self,
442        plan_id: PlanId,
443    ) -> GetScheduledTransactionsBuilder<'_> {
444        GetScheduledTransactionsBuilder {
445            client: self,
446            plan_id,
447            last_knowledge_of_server: None,
448        }
449    }
450
451    /// Returns a single scheduled transaction.
452    pub async fn get_scheduled_transaction(
453        &self,
454        plan_id: PlanId,
455        transaction_id: Uuid,
456    ) -> Result<ScheduledTransaction, Error> {
457        let result: ScheduledTransactionDataEnvelope = self
458            .get(
459                &format!(
460                    "plans/{}/scheduled_transactions/{}",
461                    plan_id, transaction_id
462                ),
463                NO_PARAMS,
464            )
465            .await?;
466        Ok(result.data.scheduled_transaction)
467    }
468}
469
470#[derive(Debug, Serialize, Deserialize)]
471struct ImportTransactionsDataEnvelope {
472    data: ImportTransactionsData,
473}
474
475#[derive(Debug, Serialize, Deserialize)]
476struct ImportTransactionsData {
477    transaction_ids: Vec<Uuid>,
478}
479
480#[derive(Debug, Default, Serialize)]
481struct Empty {}
482
483impl Client {
484    /// Delete a transaction. Returns deleted transaction and server_knowledge for delta requests
485    pub async fn delete_transaction(
486        &self,
487        plan_id: PlanId,
488        tx_id: Uuid,
489    ) -> Result<(Transaction, i64), Error> {
490        let result: TransactionDataEnvelope = self
491            .delete(&format!("plans/{}/transactions/{}", plan_id, tx_id))
492            .await?;
493        Ok((result.data.transaction, result.data.server_knowledge))
494    }
495
496    /// Imports available transactions on all linked accounts for the given
497    /// plan. The response for this endpoint contains the transaction
498    /// ids that have been imported.
499    pub async fn import_transactions(&self, plan_id: PlanId) -> Result<Vec<Uuid>, Error> {
500        let result: ImportTransactionsDataEnvelope = self
501            .post(
502                &format!("plans/{}/transactions/import", plan_id),
503                Empty::default(),
504            )
505            .await?;
506        Ok(result.data.transaction_ids)
507    }
508}
509
510/// A subtransaction within a split transaction to be created or updated.
511#[derive(Debug, Serialize)]
512pub struct SaveSubTransaction {
513    pub amount: i64,
514    #[serde(skip_serializing_if = "Option::is_none")]
515    pub payee_id: Option<Uuid>,
516    #[serde(skip_serializing_if = "Option::is_none")]
517    pub payee_name: Option<String>,
518    #[serde(skip_serializing_if = "Option::is_none")]
519    pub category_id: Option<Uuid>,
520    #[serde(skip_serializing_if = "Option::is_none")]
521    pub memo: Option<String>,
522}
523
524/// Request body for creating a new transaction.
525#[derive(Debug, Serialize)]
526pub struct NewTransaction {
527    pub account_id: Uuid,
528    pub date: NaiveDate,
529    #[serde(skip_serializing_if = "Option::is_none")]
530    pub amount: Option<i64>,
531    #[serde(skip_serializing_if = "Option::is_none")]
532    pub payee_id: Option<Uuid>,
533    #[serde(skip_serializing_if = "Option::is_none")]
534    pub payee_name: Option<String>,
535    #[serde(skip_serializing_if = "Option::is_none")]
536    pub category_id: Option<Uuid>,
537    #[serde(skip_serializing_if = "Option::is_none")]
538    pub memo: Option<String>,
539    #[serde(skip_serializing_if = "Option::is_none")]
540    pub cleared: Option<ClearedStatus>,
541    #[serde(skip_serializing_if = "Option::is_none")]
542    pub approved: Option<bool>,
543    #[serde(skip_serializing_if = "Option::is_none")]
544    pub flag_color: Option<FlagColor>,
545    #[serde(skip_serializing_if = "Option::is_none")]
546    pub import_id: Option<String>,
547    #[serde(skip_serializing_if = "Option::is_none")]
548    pub subtransactions: Option<Vec<SaveSubTransaction>>,
549}
550
551/// Request body for updating an existing transaction (PUT single).
552#[derive(Debug, Serialize)]
553pub struct ExistingTransaction {
554    #[serde(skip_serializing_if = "Option::is_none")]
555    pub account_id: Option<Uuid>,
556    #[serde(skip_serializing_if = "Option::is_none")]
557    pub date: Option<NaiveDate>,
558    #[serde(skip_serializing_if = "Option::is_none")]
559    pub amount: Option<i64>,
560    #[serde(skip_serializing_if = "Option::is_none")]
561    pub payee_id: Option<Uuid>,
562    #[serde(skip_serializing_if = "Option::is_none")]
563    pub payee_name: Option<String>,
564    #[serde(skip_serializing_if = "Option::is_none")]
565    pub category_id: Option<Uuid>,
566    #[serde(skip_serializing_if = "Option::is_none")]
567    pub memo: Option<String>,
568    #[serde(skip_serializing_if = "Option::is_none")]
569    pub cleared: Option<ClearedStatus>,
570    #[serde(skip_serializing_if = "Option::is_none")]
571    pub approved: Option<bool>,
572    #[serde(skip_serializing_if = "Option::is_none")]
573    pub flag_color: Option<FlagColor>,
574    #[serde(skip_serializing_if = "Option::is_none")]
575    pub subtransactions: Option<Vec<SaveSubTransaction>>,
576}
577
578/// Request body for a single transaction within a batch update (PATCH).
579/// Either `id` or `import_id` must be specified to identify the transaction.
580#[derive(Debug, Serialize)]
581pub struct SaveTransactionWithIdOrImportId {
582    #[serde(skip_serializing_if = "Option::is_none")]
583    pub id: Option<Uuid>,
584    #[serde(skip_serializing_if = "Option::is_none")]
585    pub import_id: Option<Uuid>,
586    #[serde(skip_serializing_if = "Option::is_none")]
587    pub account_id: Option<Uuid>,
588    #[serde(skip_serializing_if = "Option::is_none")]
589    pub date: Option<NaiveDate>,
590    #[serde(skip_serializing_if = "Option::is_none")]
591    pub amount: Option<i64>,
592    #[serde(skip_serializing_if = "Option::is_none")]
593    pub payee_id: Option<Uuid>,
594    #[serde(skip_serializing_if = "Option::is_none")]
595    pub payee_name: Option<String>,
596    #[serde(skip_serializing_if = "Option::is_none")]
597    pub category_id: Option<Uuid>,
598    #[serde(skip_serializing_if = "Option::is_none")]
599    pub memo: Option<String>,
600    #[serde(skip_serializing_if = "Option::is_none")]
601    pub cleared: Option<ClearedStatus>,
602    #[serde(skip_serializing_if = "Option::is_none")]
603    pub approved: Option<bool>,
604    #[serde(skip_serializing_if = "Option::is_none")]
605    pub flag_color: Option<FlagColor>,
606    #[serde(skip_serializing_if = "Option::is_none")]
607    pub subtransactions: Option<Vec<SaveSubTransaction>>,
608}
609
610#[derive(Debug, Serialize)]
611struct PostTransactionsWrapper {
612    transaction: Option<NewTransaction>,
613    transactions: Option<Vec<NewTransaction>>,
614}
615
616#[derive(Debug, Serialize)]
617struct PutTransactionWrapper {
618    transaction: ExistingTransaction,
619}
620
621#[derive(Debug, Serialize)]
622struct PatchTransactionsWrapper {
623    transactions: Vec<SaveTransactionWithIdOrImportId>,
624}
625
626impl Client {
627    /// Creates a single transaction. Returns the full save response including server knowledge.
628    ///
629    /// # Examples
630    ///
631    /// ```no_run
632    /// # use rust_ynab::{Client, PlanId, NewTransaction, ClearedStatus};
633    /// # use uuid::Uuid;
634    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
635    /// # let client = Client::new(&std::env::var("YNAB_TOKEN")?)?;
636    /// # let account_id: Uuid = "00000000-0000-0000-0000-000000000000".parse()?;
637    /// let resp = client.create_transaction(PlanId::LastUsed, NewTransaction {
638    ///     account_id,
639    ///     date: chrono::Local::now().date_naive(),
640    ///     amount: Some(-15000), // -$15.00
641    ///     memo: Some("Coffee".to_string()),
642    ///     cleared: Some(ClearedStatus::Cleared),
643    ///     approved: Some(true),
644    ///     payee_id: None,
645    ///     payee_name: None,
646    ///     category_id: None,
647    ///     flag_color: None,
648    ///     import_id: None,
649    ///     subtransactions: None,
650    /// }).await?;
651    /// let tx_id = resp.transaction.unwrap().id;
652    /// # Ok(()) }
653    /// ```
654    pub async fn create_transaction(
655        &self,
656        plan_id: PlanId,
657        transaction: NewTransaction,
658    ) -> Result<SaveTransactionsResponse, Error> {
659        let result: SaveTransactionsDataEnvelope = self
660            .post(
661                &format!("plans/{}/transactions", plan_id),
662                PostTransactionsWrapper {
663                    transaction: Some(transaction),
664                    transactions: None,
665                },
666            )
667            .await?;
668        Ok(result.data)
669    }
670
671    /// Creates multiple transactions. Returns the full save response including server knowledge.
672    pub async fn create_transactions(
673        &self,
674        plan_id: PlanId,
675        transactions: Vec<NewTransaction>,
676    ) -> Result<SaveTransactionsResponse, Error> {
677        let result: SaveTransactionsDataEnvelope = self
678            .post(
679                &format!("plans/{}/transactions", plan_id),
680                PostTransactionsWrapper {
681                    transaction: None,
682                    transactions: Some(transactions),
683                },
684            )
685            .await?;
686        Ok(result.data)
687    }
688
689    /// Updates multiple transactions. Returns the full save response including server knowledge.
690    pub async fn update_transactions(
691        &self,
692        plan_id: PlanId,
693        transactions: Vec<SaveTransactionWithIdOrImportId>,
694    ) -> Result<SaveTransactionsResponse, Error> {
695        let result: SaveTransactionsDataEnvelope = self
696            .patch(
697                &format!("plans/{}/transactions", plan_id),
698                PatchTransactionsWrapper { transactions },
699            )
700            .await?;
701        Ok(result.data)
702    }
703
704    /// Updates a single transaction. Returns the updated transaction and server knowledge.
705    pub async fn update_transaction(
706        &self,
707        plan_id: PlanId,
708        tx_id: Uuid,
709        transaction: ExistingTransaction,
710    ) -> Result<(Transaction, i64), Error> {
711        let result: TransactionDataEnvelope = self
712            .put(
713                &format!("plans/{}/transactions/{}", plan_id, tx_id),
714                PutTransactionWrapper { transaction },
715            )
716            .await?;
717        Ok((result.data.transaction, result.data.server_knowledge))
718    }
719
720    /// Creates a scheduled transaction.
721    pub async fn create_scheduled_transaction(
722        &self,
723        plan_id: PlanId,
724        scheduled_transaction: SaveScheduledTransaction,
725    ) -> Result<ScheduledTransaction, Error> {
726        let result: ScheduledTransactionDataEnvelope = self
727            .post(
728                &format!("plans/{}/scheduled_transactions", plan_id),
729                ScheduledTransactionWrapper {
730                    scheduled_transaction,
731                },
732            )
733            .await?;
734        Ok(result.data.scheduled_transaction)
735    }
736
737    /// Updates a scheduled transaction.
738    pub async fn update_scheduled_transaction(
739        &self,
740        plan_id: PlanId,
741        scheduled_transaction_id: Uuid,
742        scheduled_transaction: SaveScheduledTransaction,
743    ) -> Result<ScheduledTransaction, Error> {
744        let result: ScheduledTransactionDataEnvelope = self
745            .put(
746                &format!(
747                    "plans/{}/scheduled_transactions/{}",
748                    plan_id, scheduled_transaction_id
749                ),
750                ScheduledTransactionWrapper {
751                    scheduled_transaction,
752                },
753            )
754            .await?;
755        Ok(result.data.scheduled_transaction)
756    }
757
758    /// Deletes a scheduled transaction.
759    pub async fn delete_scheduled_transaction(
760        &self,
761        plan_id: PlanId,
762        scheduled_transaction_id: Uuid,
763    ) -> Result<ScheduledTransaction, Error> {
764        let result: ScheduledTransactionDataEnvelope = self
765            .delete(&format!(
766                "plans/{}/scheduled_transactions/{}",
767                plan_id, scheduled_transaction_id
768            ))
769            .await?;
770        Ok(result.data.scheduled_transaction)
771    }
772}
773
774/// Request body for creating or updating a scheduled transaction.
775#[derive(Debug, Serialize)]
776pub struct SaveScheduledTransaction {
777    pub account_id: Uuid,
778    pub date: NaiveDate,
779    #[serde(skip_serializing_if = "Option::is_none")]
780    pub amount: Option<i64>,
781    #[serde(skip_serializing_if = "Option::is_none")]
782    pub payee_id: Option<Uuid>,
783    #[serde(skip_serializing_if = "Option::is_none")]
784    pub payee_name: Option<String>,
785    #[serde(skip_serializing_if = "Option::is_none")]
786    pub category_id: Option<Uuid>,
787    #[serde(skip_serializing_if = "Option::is_none")]
788    pub memo: Option<String>,
789    #[serde(skip_serializing_if = "Option::is_none")]
790    pub flag_color: Option<FlagColor>,
791    #[serde(skip_serializing_if = "Option::is_none")]
792    pub frequency: Option<Frequency>,
793}
794
795#[derive(Debug, Serialize)]
796struct ScheduledTransactionWrapper {
797    scheduled_transaction: SaveScheduledTransaction,
798}
799
800#[cfg(test)]
801mod tests {
802    use super::*;
803    use crate::ynab::testutil::{
804        TEST_ID_1, TEST_ID_3, TEST_ID_4, error_body, new_test_client,
805        scheduled_transaction_fixture, transaction_fixture,
806    };
807    use serde_json::json;
808    use uuid::uuid;
809    use wiremock::matchers::{method, path};
810    use wiremock::{Mock, ResponseTemplate};
811
812    fn transactions_list_fixture() -> serde_json::Value {
813        json!({ "data": { "transactions": [transaction_fixture()], "server_knowledge": 10 } })
814    }
815
816    fn transaction_single_fixture() -> serde_json::Value {
817        json!({ "data": { "transaction": transaction_fixture(), "server_knowledge": 10 } })
818    }
819
820    fn save_transactions_fixture() -> serde_json::Value {
821        json!({
822            "data": {
823                "transaction_ids": [TEST_ID_1],
824                "transaction": transaction_fixture(),
825                "transactions": [transaction_fixture()],
826                "duplicate_import_ids": null,
827                "server_knowledge": 10
828            }
829        })
830    }
831
832    fn scheduled_transactions_list_fixture() -> serde_json::Value {
833        json!({
834            "data": {
835                "scheduled_transactions": [scheduled_transaction_fixture()],
836                "server_knowledge": 10
837            }
838        })
839    }
840
841    fn scheduled_transaction_single_fixture() -> serde_json::Value {
842        json!({ "data": { "scheduled_transaction": scheduled_transaction_fixture() } })
843    }
844
845    fn import_transactions_fixture() -> serde_json::Value {
846        json!({ "data": { "transaction_ids": [TEST_ID_1] } })
847    }
848
849    #[tokio::test]
850    async fn get_transactions_returns_transactions() {
851        let (client, server) = new_test_client().await;
852        Mock::given(method("GET"))
853            .and(path(format!("/plans/{}/transactions", TEST_ID_1)))
854            .respond_with(ResponseTemplate::new(200).set_body_json(transactions_list_fixture()))
855            .expect(1)
856            .mount(&server)
857            .await;
858        let (txs, sk) = client
859            .get_transactions(PlanId::Id(uuid!(TEST_ID_1)))
860            .send()
861            .await
862            .unwrap();
863        assert_eq!(txs.len(), 1);
864        assert_eq!(txs[0].id.to_string(), TEST_ID_1);
865        assert_eq!(txs[0].amount, -50000);
866        assert_eq!(sk, 10);
867    }
868
869    #[tokio::test]
870    async fn get_transaction_returns_transaction() {
871        let (client, server) = new_test_client().await;
872        Mock::given(method("GET"))
873            .and(path(format!(
874                "/plans/{}/transactions/{}",
875                TEST_ID_1, TEST_ID_1
876            )))
877            .respond_with(ResponseTemplate::new(200).set_body_json(transaction_single_fixture()))
878            .expect(1)
879            .mount(&server)
880            .await;
881        let (tx, sk) = client
882            .get_transaction(PlanId::Id(uuid!(TEST_ID_1)), &uuid!(TEST_ID_1))
883            .await
884            .unwrap();
885        assert_eq!(tx.id.to_string(), TEST_ID_1);
886        assert_eq!(tx.amount, -50000);
887        assert_eq!(sk, 10);
888    }
889
890    #[tokio::test]
891    async fn get_transactions_by_account_returns_transactions() {
892        let (client, server) = new_test_client().await;
893        Mock::given(method("GET"))
894            .and(path(format!(
895                "/plans/{}/accounts/{}/transactions",
896                TEST_ID_1, TEST_ID_1
897            )))
898            .respond_with(ResponseTemplate::new(200).set_body_json(transactions_list_fixture()))
899            .expect(1)
900            .mount(&server)
901            .await;
902        let (txs, _) = client
903            .get_transactions_by_account(PlanId::Id(uuid!(TEST_ID_1)), uuid!(TEST_ID_1))
904            .send()
905            .await
906            .unwrap();
907        assert_eq!(txs.len(), 1);
908    }
909
910    #[tokio::test]
911    async fn get_transactions_by_category_returns_transactions() {
912        let (client, server) = new_test_client().await;
913        Mock::given(method("GET"))
914            .and(path(format!(
915                "/plans/{}/categories/{}/transactions",
916                TEST_ID_1, TEST_ID_1
917            )))
918            .respond_with(ResponseTemplate::new(200).set_body_json(transactions_list_fixture()))
919            .expect(1)
920            .mount(&server)
921            .await;
922        let (txs, _) = client
923            .get_transactions_by_category(PlanId::Id(uuid!(TEST_ID_1)), uuid!(TEST_ID_1))
924            .send()
925            .await
926            .unwrap();
927        assert_eq!(txs.len(), 1);
928    }
929
930    #[tokio::test]
931    async fn get_transactions_by_payee_returns_transactions() {
932        let (client, server) = new_test_client().await;
933        Mock::given(method("GET"))
934            .and(path(format!(
935                "/plans/{}/payees/{}/transactions",
936                TEST_ID_1, TEST_ID_3
937            )))
938            .respond_with(ResponseTemplate::new(200).set_body_json(transactions_list_fixture()))
939            .expect(1)
940            .mount(&server)
941            .await;
942        let (txs, _) = client
943            .get_transactions_by_payee(PlanId::Id(uuid!(TEST_ID_1)), uuid!(TEST_ID_3))
944            .send()
945            .await
946            .unwrap();
947        assert_eq!(txs.len(), 1);
948    }
949
950    #[tokio::test]
951    async fn get_transactions_by_month_returns_transactions() {
952        let (client, server) = new_test_client().await;
953        let month = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
954        Mock::given(method("GET"))
955            .and(path(format!(
956                "/plans/{}/months/{}/transactions",
957                TEST_ID_1, month
958            )))
959            .respond_with(ResponseTemplate::new(200).set_body_json(transactions_list_fixture()))
960            .expect(1)
961            .mount(&server)
962            .await;
963        let (txs, _) = client
964            .get_transactions_by_month(PlanId::Id(uuid!(TEST_ID_1)), month)
965            .send()
966            .await
967            .unwrap();
968        assert_eq!(txs.len(), 1);
969    }
970
971    #[tokio::test]
972    async fn create_transaction_succeeds() {
973        let (client, server) = new_test_client().await;
974        Mock::given(method("POST"))
975            .and(path(format!("/plans/{}/transactions", TEST_ID_1)))
976            .respond_with(ResponseTemplate::new(201).set_body_json(save_transactions_fixture()))
977            .expect(1)
978            .mount(&server)
979            .await;
980        let resp = client
981            .create_transaction(
982                PlanId::Id(uuid!(TEST_ID_1)),
983                NewTransaction {
984                    account_id: uuid!(TEST_ID_1),
985                    date: NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
986                    amount: Some(-50000),
987                    memo: None,
988                    cleared: Some(ClearedStatus::Cleared),
989                    approved: Some(true),
990                    payee_id: None,
991                    payee_name: None,
992                    category_id: None,
993                    flag_color: None,
994                    import_id: None,
995                    subtransactions: None,
996                },
997            )
998            .await
999            .unwrap();
1000        assert_eq!(resp.transaction_ids, vec![uuid!(TEST_ID_1)]);
1001        assert_eq!(resp.transaction.unwrap().amount, -50000);
1002    }
1003
1004    #[tokio::test]
1005    async fn create_transactions_succeeds() {
1006        let (client, server) = new_test_client().await;
1007        Mock::given(method("POST"))
1008            .and(path(format!("/plans/{}/transactions", TEST_ID_1)))
1009            .respond_with(ResponseTemplate::new(201).set_body_json(save_transactions_fixture()))
1010            .expect(1)
1011            .mount(&server)
1012            .await;
1013        let resp = client
1014            .create_transactions(
1015                PlanId::Id(uuid!(TEST_ID_1)),
1016                vec![NewTransaction {
1017                    account_id: uuid!(TEST_ID_1),
1018                    date: NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1019                    amount: Some(-50000),
1020                    memo: None,
1021                    cleared: Some(ClearedStatus::Cleared),
1022                    approved: Some(true),
1023                    payee_id: None,
1024                    payee_name: None,
1025                    category_id: None,
1026                    flag_color: None,
1027                    import_id: None,
1028                    subtransactions: None,
1029                }],
1030            )
1031            .await
1032            .unwrap();
1033        assert_eq!(resp.transaction_ids, vec![uuid!(TEST_ID_1)]);
1034    }
1035
1036    #[tokio::test]
1037    async fn update_transaction_succeeds() {
1038        let (client, server) = new_test_client().await;
1039        Mock::given(method("PUT"))
1040            .and(path(format!(
1041                "/plans/{}/transactions/{}",
1042                TEST_ID_1, TEST_ID_1
1043            )))
1044            .respond_with(ResponseTemplate::new(200).set_body_json(transaction_single_fixture()))
1045            .expect(1)
1046            .mount(&server)
1047            .await;
1048        let (tx, sk) = client
1049            .update_transaction(
1050                PlanId::Id(uuid!(TEST_ID_1)),
1051                uuid!(TEST_ID_1),
1052                ExistingTransaction {
1053                    amount: Some(-50000),
1054                    account_id: None,
1055                    date: None,
1056                    payee_id: None,
1057                    payee_name: None,
1058                    category_id: None,
1059                    memo: None,
1060                    cleared: None,
1061                    approved: None,
1062                    flag_color: None,
1063                    subtransactions: None,
1064                },
1065            )
1066            .await
1067            .unwrap();
1068        assert_eq!(tx.id.to_string(), TEST_ID_1);
1069        assert_eq!(sk, 10);
1070    }
1071
1072    #[tokio::test]
1073    async fn update_transactions_succeeds() {
1074        let (client, server) = new_test_client().await;
1075        Mock::given(method("PATCH"))
1076            .and(path(format!("/plans/{}/transactions", TEST_ID_1)))
1077            .respond_with(ResponseTemplate::new(200).set_body_json(save_transactions_fixture()))
1078            .expect(1)
1079            .mount(&server)
1080            .await;
1081        let resp = client
1082            .update_transactions(
1083                PlanId::Id(uuid!(TEST_ID_1)),
1084                vec![SaveTransactionWithIdOrImportId {
1085                    id: Some(Uuid::from_bytes([0; 16])),
1086                    memo: Some("updated".to_string()),
1087                    import_id: None,
1088                    account_id: None,
1089                    date: None,
1090                    amount: None,
1091                    payee_id: None,
1092                    payee_name: None,
1093                    category_id: None,
1094                    cleared: None,
1095                    approved: None,
1096                    flag_color: None,
1097                    subtransactions: None,
1098                }],
1099            )
1100            .await
1101            .unwrap();
1102        assert_eq!(resp.transaction_ids, vec![uuid!(TEST_ID_1)]);
1103    }
1104
1105    #[tokio::test]
1106    async fn delete_transaction_succeeds() {
1107        let (client, server) = new_test_client().await;
1108        Mock::given(method("DELETE"))
1109            .and(path(format!(
1110                "/plans/{}/transactions/{}",
1111                TEST_ID_1, TEST_ID_1
1112            )))
1113            .respond_with(ResponseTemplate::new(200).set_body_json(transaction_single_fixture()))
1114            .expect(1)
1115            .mount(&server)
1116            .await;
1117        let (tx, sk) = client
1118            .delete_transaction(PlanId::Id(uuid!(TEST_ID_1)), uuid!(TEST_ID_1))
1119            .await
1120            .unwrap();
1121        assert_eq!(tx.id.to_string(), TEST_ID_1);
1122        assert_eq!(sk, 10);
1123    }
1124
1125    #[tokio::test]
1126    async fn import_transactions_returns_ids() {
1127        let (client, server) = new_test_client().await;
1128        Mock::given(method("POST"))
1129            .and(path(format!("/plans/{}/transactions/import", TEST_ID_1)))
1130            .respond_with(ResponseTemplate::new(200).set_body_json(import_transactions_fixture()))
1131            .expect(1)
1132            .mount(&server)
1133            .await;
1134        let ids = client
1135            .import_transactions(PlanId::Id(uuid!(TEST_ID_1)))
1136            .await
1137            .unwrap();
1138        assert_eq!(ids.len(), 1);
1139        assert_eq!(ids[0].to_string(), TEST_ID_1);
1140    }
1141
1142    #[tokio::test]
1143    async fn get_scheduled_transactions_returns_transactions() {
1144        let (client, server) = new_test_client().await;
1145        Mock::given(method("GET"))
1146            .and(path(format!("/plans/{}/scheduled_transactions", TEST_ID_1)))
1147            .respond_with(
1148                ResponseTemplate::new(200).set_body_json(scheduled_transactions_list_fixture()),
1149            )
1150            .expect(1)
1151            .mount(&server)
1152            .await;
1153        let (txs, sk) = client
1154            .get_scheduled_transactions(PlanId::Id(uuid!(TEST_ID_1)))
1155            .send()
1156            .await
1157            .unwrap();
1158        assert_eq!(txs.len(), 1);
1159        assert_eq!(txs[0].id.to_string(), TEST_ID_4);
1160        assert_eq!(sk, 10);
1161    }
1162
1163    #[tokio::test]
1164    async fn get_scheduled_transaction_returns_transaction() {
1165        let (client, server) = new_test_client().await;
1166        Mock::given(method("GET"))
1167            .and(path(format!(
1168                "/plans/{}/scheduled_transactions/{}",
1169                TEST_ID_1, TEST_ID_4
1170            )))
1171            .respond_with(
1172                ResponseTemplate::new(200).set_body_json(scheduled_transaction_single_fixture()),
1173            )
1174            .expect(1)
1175            .mount(&server)
1176            .await;
1177        let tx = client
1178            .get_scheduled_transaction(PlanId::Id(uuid!(TEST_ID_1)), uuid!(TEST_ID_4))
1179            .await
1180            .unwrap();
1181        assert_eq!(tx.id.to_string(), TEST_ID_4);
1182        assert!(matches!(tx.frequency, Frequency::Monthly));
1183    }
1184
1185    #[tokio::test]
1186    async fn create_scheduled_transaction_succeeds() {
1187        let (client, server) = new_test_client().await;
1188        Mock::given(method("POST"))
1189            .and(path(format!("/plans/{}/scheduled_transactions", TEST_ID_1)))
1190            .respond_with(
1191                ResponseTemplate::new(201).set_body_json(scheduled_transaction_single_fixture()),
1192            )
1193            .expect(1)
1194            .mount(&server)
1195            .await;
1196        let tx = client
1197            .create_scheduled_transaction(
1198                PlanId::Id(uuid!(TEST_ID_1)),
1199                SaveScheduledTransaction {
1200                    account_id: uuid!(TEST_ID_1),
1201                    date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1202                    amount: Some(-50000),
1203                    frequency: Some(Frequency::Monthly),
1204                    memo: None,
1205                    payee_id: None,
1206                    payee_name: None,
1207                    category_id: None,
1208                    flag_color: None,
1209                },
1210            )
1211            .await
1212            .unwrap();
1213        assert_eq!(tx.id.to_string(), TEST_ID_4);
1214        assert_eq!(tx.amount, -50000);
1215    }
1216
1217    #[tokio::test]
1218    async fn update_scheduled_transaction_succeeds() {
1219        let (client, server) = new_test_client().await;
1220        Mock::given(method("PUT"))
1221            .and(path(format!(
1222                "/plans/{}/scheduled_transactions/{}",
1223                TEST_ID_1, TEST_ID_4
1224            )))
1225            .respond_with(
1226                ResponseTemplate::new(200).set_body_json(scheduled_transaction_single_fixture()),
1227            )
1228            .expect(1)
1229            .mount(&server)
1230            .await;
1231        let tx = client
1232            .update_scheduled_transaction(
1233                PlanId::Id(uuid!(TEST_ID_1)),
1234                uuid!(TEST_ID_4),
1235                SaveScheduledTransaction {
1236                    account_id: uuid!(TEST_ID_1),
1237                    date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1238                    amount: Some(-50000),
1239                    frequency: Some(Frequency::Monthly),
1240                    memo: None,
1241                    payee_id: None,
1242                    payee_name: None,
1243                    category_id: None,
1244                    flag_color: None,
1245                },
1246            )
1247            .await
1248            .unwrap();
1249        assert_eq!(tx.id.to_string(), TEST_ID_4);
1250    }
1251
1252    #[tokio::test]
1253    async fn delete_scheduled_transaction_succeeds() {
1254        let (client, server) = new_test_client().await;
1255        Mock::given(method("DELETE"))
1256            .and(path(format!(
1257                "/plans/{}/scheduled_transactions/{}",
1258                TEST_ID_1, TEST_ID_4
1259            )))
1260            .respond_with(
1261                ResponseTemplate::new(200).set_body_json(scheduled_transaction_single_fixture()),
1262            )
1263            .expect(1)
1264            .mount(&server)
1265            .await;
1266        let tx = client
1267            .delete_scheduled_transaction(PlanId::Id(uuid!(TEST_ID_1)), uuid!(TEST_ID_4))
1268            .await
1269            .unwrap();
1270        assert_eq!(tx.id.to_string(), TEST_ID_4);
1271    }
1272
1273    #[tokio::test]
1274    async fn get_transaction_returns_not_found() {
1275        let (client, server) = new_test_client().await;
1276        Mock::given(method("GET"))
1277            .and(path(format!(
1278                "/plans/{}/transactions/{}",
1279                TEST_ID_1, TEST_ID_1
1280            )))
1281            .respond_with(ResponseTemplate::new(404).set_body_json(error_body(
1282                "404",
1283                "not_found",
1284                "Transaction not found",
1285            )))
1286            .mount(&server)
1287            .await;
1288        let err = client
1289            .get_transaction(PlanId::Id(uuid!(TEST_ID_1)), &uuid!(TEST_ID_1))
1290            .await
1291            .unwrap_err();
1292        assert!(matches!(err, Error::NotFound(_)));
1293    }
1294}