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