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