Skip to main content

rust_ynab/ynab/
plan.rs

1use chrono::{DateTime, NaiveDate};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5use crate::Account;
6use crate::Client;
7use crate::Error;
8use crate::Month;
9use crate::ynab::common::NO_PARAMS;
10use crate::{Category, CategoryGroup};
11use crate::{CurrencyFormat, DateFormat};
12use crate::{Payee, PayeeLocation};
13use crate::{ScheduledSubtransaction, ScheduledTransaction, Subtransaction, Transaction};
14
15/// Identifies a plan for use in API requests.
16///
17/// Use `PlanId::Id(uuid)` for a specific plan, `PlanId::LastUsed` for the most recently
18/// accessed plan, or `PlanId::Default` for the default plan.
19#[derive(Debug, Clone, Copy)]
20pub enum PlanId {
21    Id(Uuid),
22    LastUsed,
23    Default,
24}
25
26impl std::fmt::Display for PlanId {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        match self {
29            Self::Id(id) => write!(f, "{id}"),
30            Self::LastUsed => write!(f, "last-used"),
31            Self::Default => write!(f, "default"),
32        }
33    }
34}
35
36impl From<Uuid> for PlanId {
37    fn from(value: Uuid) -> Self {
38        Self::Id(value)
39    }
40}
41#[derive(Debug, Deserialize)]
42struct PlanDataEnvelope {
43    data: PlanData,
44}
45
46#[derive(Debug, Deserialize)]
47struct PlanData {
48    plans: Vec<Plan>,
49    // users can use PlanId::Default to directly interact with the default plan
50    _default_plan: Option<Plan>,
51}
52
53/// Summary information for a plan.
54#[derive(Debug, Serialize, Deserialize)]
55pub struct Plan {
56    pub id: Uuid,
57    pub name: String,
58    pub last_modified_on: DateTime<chrono::Utc>,
59    pub first_month: NaiveDate,
60    pub last_month: NaiveDate,
61    pub date_format: DateFormat,
62    pub currency_format: CurrencyFormat,
63    #[serde(default)]
64    pub accounts: Vec<Account>,
65}
66
67#[derive(Debug, Serialize, Deserialize)]
68struct PlanSettingsDataEnvelope {
69    data: PlanSettingsData,
70}
71
72#[derive(Debug, Serialize, Deserialize)]
73struct PlanSettingsData {
74    settings: PlanSettings,
75}
76
77/// Date and currency format settings for a plan.
78#[derive(Debug, Serialize, Deserialize)]
79pub struct PlanSettings {
80    pub date_format: DateFormat,
81    pub currency_format: CurrencyFormat,
82}
83
84#[derive(Debug, Serialize, Deserialize)]
85struct PlanDetailsDataEnvelope {
86    data: PlanDetailsData,
87}
88
89#[derive(Debug, Serialize, Deserialize)]
90struct PlanDetailsData {
91    plan: PlanDetails,
92    server_knowledge: i64,
93}
94
95/// A single plan with all related entities. This resource is effectively a full plan export.
96#[derive(Debug, Serialize, Deserialize)]
97pub struct PlanDetails {
98    #[serde(flatten)]
99    pub plan: Plan,
100    pub payees: Vec<Payee>,
101    pub payee_locations: Vec<PayeeLocation>,
102    pub category_groups: Vec<CategoryGroup>,
103    pub categories: Vec<Category>,
104    pub months: Vec<Month>,
105    pub transactions: Vec<Transaction>,
106    pub subtransactions: Vec<Subtransaction>,
107    pub scheduled_transactions: Vec<ScheduledTransaction>,
108    pub scheduled_subtransactions: Vec<ScheduledSubtransaction>,
109}
110
111impl PlanDetails {
112    pub fn id(&self) -> PlanId {
113        PlanId::Id(self.plan.id)
114    }
115}
116
117#[derive(Debug)]
118pub struct GetPlansBuilder<'a> {
119    client: &'a Client,
120    include_accounts: bool,
121}
122
123impl<'a> GetPlansBuilder<'a> {
124    /// Includes account details inline in each plan.
125    pub fn include_accounts(mut self) -> GetPlansBuilder<'a> {
126        self.include_accounts = true;
127        self
128    }
129
130    /// Sends the request. Returns a list of plans with summary information.
131    pub async fn send(self) -> Result<Vec<Plan>, Error> {
132        let params: Option<&[(&str, &str)]> = if self.include_accounts {
133            Some(&[("include_accounts", "true")])
134        } else {
135            None
136        };
137        let result: PlanDataEnvelope = self.client.get("plans", params).await?;
138        Ok(result.data.plans)
139    }
140}
141
142#[derive(Debug)]
143pub struct GetPlanBuilder<'a> {
144    client: &'a Client,
145    plan_id: PlanId,
146    last_knowledge_of_server: Option<i64>,
147}
148
149impl<'a> GetPlanBuilder<'a> {
150    /// Requests only changes since a previous sync. Pass the `server_knowledge` value
151    /// returned by a prior call.
152    pub fn with_server_knowledge(mut self, sk: i64) -> GetPlanBuilder<'a> {
153        self.last_knowledge_of_server = Some(sk);
154        self
155    }
156
157    /// Sends the request. Returns the full plan export and server knowledge for use in subsequent delta requests.
158    pub async fn send(self) -> Result<(PlanDetails, i64), Error> {
159        let params: Option<&[(&str, &str)]> = if let Some(sk) = self.last_knowledge_of_server {
160            Some(&[("last_knowledge_of_server", &sk.to_string())])
161        } else {
162            None
163        };
164        let result: PlanDetailsDataEnvelope = self
165            .client
166            .get(&format!("plans/{}", self.plan_id), params)
167            .await?;
168        Ok((result.data.plan, result.data.server_knowledge))
169    }
170}
171impl Client {
172    /// Returns a builder for fetching all plans. Chain `.include_accounts()` to embed account
173    /// details inline.
174    ///
175    /// # Examples
176    ///
177    /// ```no_run
178    /// # use rust_ynab::Client;
179    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
180    /// let client = Client::new(&std::env::var("YNAB_TOKEN")?)?;
181    /// let plans = client.get_plans().include_accounts().send().await?;
182    /// for plan in &plans {
183    ///     println!("{}: {} accounts", plan.name, plan.accounts.len());
184    /// }
185    /// # Ok(()) }
186    /// ```
187    pub fn get_plans(&self) -> GetPlansBuilder<'_> {
188        GetPlansBuilder {
189            client: self,
190            include_accounts: false,
191        }
192    }
193
194    /// Returns settings for a plan.
195    pub async fn get_plan_settings(&self, plan_id: PlanId) -> Result<PlanSettings, Error> {
196        let result: PlanSettingsDataEnvelope = self
197            .get(&format!("plans/{}/settings", plan_id), NO_PARAMS)
198            .await?;
199        Ok(result.data.settings)
200    }
201
202    /// Returns a builder for fetching a single plan with all related entities (a full plan export).
203    /// Chain `.with_server_knowledge()` for a delta request.
204    pub fn get_plan(&self, plan_id: PlanId) -> GetPlanBuilder<'_> {
205        GetPlanBuilder {
206            plan_id,
207            client: self,
208            last_knowledge_of_server: None,
209        }
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use crate::ynab::testutil::{
217        TEST_ID_5, error_body, new_test_client, plan_details_fixture, plan_fixture,
218    };
219    use serde_json::json;
220    use wiremock::matchers::{method, path};
221    use wiremock::{Mock, ResponseTemplate};
222
223    fn plans_list_fixture() -> serde_json::Value {
224        json!({ "data": { "plans": [plan_fixture()], "_default_plan": null } })
225    }
226
227    fn plan_single_fixture() -> serde_json::Value {
228        json!({ "data": { "plan": plan_details_fixture(), "server_knowledge": 5 } })
229    }
230
231    fn plan_settings_fixture() -> serde_json::Value {
232        json!({
233            "data": {
234                "settings": {
235                    "date_format": { "format": "MM/DD/YYYY" },
236                    "currency_format": {
237                        "iso_code": "USD", "example_format": "123,456.78", "decimal_digits": 2,
238                        "decimal_separator": ".", "symbol_first": true, "group_separator": ",",
239                        "currency_symbol": "$", "display_symbol": true
240                    }
241                }
242            }
243        })
244    }
245
246    #[tokio::test]
247    async fn get_plans_returns_plans() {
248        let (client, server) = new_test_client().await;
249        Mock::given(method("GET"))
250            .and(path("/plans"))
251            .respond_with(ResponseTemplate::new(200).set_body_json(plans_list_fixture()))
252            .expect(1)
253            .mount(&server)
254            .await;
255        let plans = client.get_plans().send().await.unwrap();
256        assert_eq!(plans.len(), 1);
257        assert_eq!(plans[0].id.to_string(), TEST_ID_5);
258        assert_eq!(plans[0].name, "My Budget");
259    }
260
261    #[tokio::test]
262    async fn get_plan_returns_plan_and_server_knowledge() {
263        let (client, server) = new_test_client().await;
264        Mock::given(method("GET"))
265            .and(path("/plans/last-used"))
266            .respond_with(ResponseTemplate::new(200).set_body_json(plan_single_fixture()))
267            .expect(1)
268            .mount(&server)
269            .await;
270        let (plan, sk) = client.get_plan(PlanId::LastUsed).send().await.unwrap();
271        assert_eq!(plan.plan.id.to_string(), TEST_ID_5);
272        assert_eq!(plan.plan.name, "My Budget");
273        assert_eq!(sk, 5);
274    }
275
276    #[tokio::test]
277    async fn get_plan_settings_returns_settings() {
278        let (client, server) = new_test_client().await;
279        Mock::given(method("GET"))
280            .and(path("/plans/last-used/settings"))
281            .respond_with(ResponseTemplate::new(200).set_body_json(plan_settings_fixture()))
282            .expect(1)
283            .mount(&server)
284            .await;
285        let settings = client.get_plan_settings(PlanId::LastUsed).await.unwrap();
286        assert_eq!(settings.currency_format.iso_code, "USD");
287    }
288
289    #[tokio::test]
290    async fn get_plan_returns_unauthorized() {
291        let (client, server) = new_test_client().await;
292        Mock::given(method("GET"))
293            .and(path("/plans/last-used"))
294            .respond_with(ResponseTemplate::new(401).set_body_json(error_body(
295                "401",
296                "unauthorized",
297                "Unauthorized",
298            )))
299            .mount(&server)
300            .await;
301        let err = client.get_plan(PlanId::LastUsed).send().await.unwrap_err();
302        assert!(matches!(err, Error::Unauthorized(_)));
303    }
304}