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