Skip to main content

rust_ynab/ynab/
transaction.rs

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