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