Skip to main content

rust_ynab/ynab/
transaction.rs

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