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