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 _default_plan: Option<Plan>,
47}
48
49#[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#[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 pub fn get_plans(&self) -> GetPlansBuilder<'_> {
164 GetPlansBuilder {
165 client: self,
166 include_accounts: false,
167 }
168 }
169
170 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 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}