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