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