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