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#[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 #[allow(dead_code)]
53 default_plan: Option<Plan>,
54}
55
56#[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#[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#[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 pub fn include_accounts(mut self) -> GetPlansBuilder<'a> {
129 self.include_accounts = true;
130 self
131 }
132
133 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 pub fn with_server_knowledge(mut self, sk: i64) -> GetPlanBuilder<'a> {
156 self.last_knowledge_of_server = Some(sk);
157 self
158 }
159
160 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 pub fn get_plans(&self) -> GetPlansBuilder<'_> {
191 GetPlansBuilder {
192 client: self,
193 include_accounts: false,
194 }
195 }
196
197 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 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}