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, 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 #[allow(dead_code)]
51 default_plan: Option<Plan>,
52}
53
54#[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#[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#[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 pub fn include_accounts(mut self) -> GetPlansBuilder<'a> {
127 self.include_accounts = true;
128 self
129 }
130
131 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 pub fn with_server_knowledge(mut self, sk: i64) -> GetPlanBuilder<'a> {
154 self.last_knowledge_of_server = Some(sk);
155 self
156 }
157
158 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 pub fn get_plans(&self) -> GetPlansBuilder<'_> {
189 GetPlansBuilder {
190 client: self,
191 include_accounts: false,
192 }
193 }
194
195 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 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}