1use chrono::DateTime;
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5use crate::Client;
6use crate::Error;
7use crate::PlanId;
8use crate::ynab::common::NO_PARAMS;
9
10#[derive(Debug, Deserialize, Serialize)]
11struct AccountDataEnvelope {
12 data: AccountData,
13}
14
15#[derive(Debug, Deserialize, Serialize)]
16struct AccountData {
17 account: Account,
18}
19
20#[derive(Debug, Deserialize, Serialize)]
21struct AccountsDataEnvelope {
22 data: AccountsData,
23}
24
25#[derive(Debug, Deserialize, Serialize)]
26struct AccountsData {
27 accounts: Vec<Account>,
28 server_knowledge: i64,
29}
30
31#[derive(Debug, Deserialize, Serialize)]
33#[serde(rename_all = "camelCase")]
34pub enum AccountType {
35 Checking,
36 Savings,
37 Cash,
38 CreditCard,
39 LineOfCredit,
40 OtherAsset,
41 OtherLiability,
42 Mortgage,
43 AutoLoan,
44 StudentLoan,
45 PersonalLoan,
46 MedicalDebt,
47 OtherDebt,
48}
49
50#[derive(Debug, Deserialize, Serialize)]
52pub struct Account {
53 pub id: Uuid,
54 pub name: String,
55 #[serde(rename = "type")]
56 pub acct_type: AccountType,
57 pub on_budget: bool,
58 pub closed: bool,
59 pub note: Option<String>,
60 pub balance: i64,
61 pub cleared_balance: i64,
62 pub uncleared_balance: i64,
63 pub transfer_payee_id: Option<uuid::Uuid>,
64 pub direct_import_linked: bool,
65 pub direct_import_in_error: bool,
66 pub last_reconciled_at: Option<DateTime<chrono::Utc>>,
67 pub deleted: bool,
68}
69
70#[derive(Debug)]
71pub struct GetAccountsBuilder<'a> {
72 client: &'a Client,
73 plan_id: PlanId,
74 last_knowledge_of_server: Option<i64>,
75}
76
77impl<'a> GetAccountsBuilder<'a> {
78 pub fn with_server_knowledge(mut self, sk: i64) -> GetAccountsBuilder<'a> {
79 self.last_knowledge_of_server = Some(sk);
80 self
81 }
82
83 pub async fn send(self) -> Result<(Vec<Account>, i64), Error> {
84 let params: Option<&[(&str, &str)]> = if let Some(sk) = self.last_knowledge_of_server {
85 Some(&[("last_knowledge_of_server", &sk.to_string())])
86 } else {
87 None
88 };
89 let result: AccountsDataEnvelope = self
90 .client
91 .get(&format!("plans/{}/accounts", self.plan_id), params)
92 .await?;
93 Ok((result.data.accounts, result.data.server_knowledge))
94 }
95}
96
97impl Client {
98 pub fn get_accounts(&self, plan_id: PlanId) -> GetAccountsBuilder<'_> {
100 GetAccountsBuilder {
101 client: self,
102 plan_id,
103 last_knowledge_of_server: None,
104 }
105 }
106
107 pub async fn get_account(&self, plan_id: PlanId, account_id: Uuid) -> Result<Account, Error> {
109 let result: AccountDataEnvelope = self
110 .get(
111 &format!("plans/{}/accounts/{}", plan_id, account_id),
112 NO_PARAMS,
113 )
114 .await?;
115 Ok(result.data.account)
116 }
117}
118
119#[derive(Debug, Serialize, Deserialize)]
121#[serde(rename_all = "camelCase")]
122pub enum SaveAccountType {
123 Checking,
124 Savings,
125 Cash,
126 CreditCard,
127 LineOfCredit,
128 OtherAsset,
129 OtherLiability,
130 Mortgage,
131 AutoLoan,
132 StudentLoan,
133 PersonalLoan,
134 MedicalDebt,
135 OtherDebt,
136}
137
138impl TryFrom<&str> for SaveAccountType {
139 type Error = String;
140
141 fn try_from(value: &str) -> Result<Self, Self::Error> {
142 match value {
143 "checking" => Ok(SaveAccountType::Checking),
144 "savings" => Ok(SaveAccountType::Savings),
145 "cash" => Ok(SaveAccountType::Cash),
146 "creditCard" => Ok(SaveAccountType::CreditCard),
147 "lineOfCredit" => Ok(SaveAccountType::LineOfCredit),
148 "otherAsset" => Ok(SaveAccountType::OtherAsset),
149 "otherLiability" => Ok(SaveAccountType::OtherLiability),
150 "mortgage" => Ok(SaveAccountType::Mortgage),
151 "autoLoan" => Ok(SaveAccountType::AutoLoan),
152 "studentLoan" => Ok(SaveAccountType::StudentLoan),
153 "personalLoan" => Ok(SaveAccountType::PersonalLoan),
154 "medicalDebt" => Ok(SaveAccountType::MedicalDebt),
155 "otherDebt" => Ok(SaveAccountType::OtherDebt),
156 _ => Err(format!("unknown account type: {}", value)),
157 }
158 }
159}
160
161#[derive(Debug, Serialize)]
163pub struct SaveAccount {
164 pub name: String,
165 #[serde(rename = "type")]
166 pub acct_type: SaveAccountType,
167 pub balance: i64,
168}
169
170#[derive(Debug, Serialize)]
171struct SaveAccountBody {
172 account: SaveAccount,
173}
174
175impl Client {
176 pub async fn create_account(
178 &self,
179 plan_id: PlanId,
180 account: SaveAccount,
181 ) -> Result<Account, Error> {
182 let response: AccountDataEnvelope = self
183 .post(
184 &format!("plans/{}/accounts", plan_id),
185 SaveAccountBody { account },
186 )
187 .await?;
188 Ok(response.data.account)
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195 use crate::ynab::testutil::{TEST_ID_1, account_fixture, error_body, new_test_client};
196 use uuid::uuid;
197 use wiremock::matchers::{method, path};
198 use wiremock::{Mock, ResponseTemplate};
199
200 fn account_list_fixture() -> serde_json::Value {
201 serde_json::json!({
202 "data": {
203 "accounts": [account_fixture(), account_fixture()],
204 "server_knowledge": 7
205 }
206 })
207 }
208
209 fn account_single_fixture() -> serde_json::Value {
210 serde_json::json!({
211 "data": { "account": account_fixture() }
212 })
213 }
214
215 #[tokio::test]
216 async fn get_accounts_returns_ids() {
217 let (client, server) = new_test_client().await;
218
219 Mock::given(method("GET"))
220 .and(path("/plans/last-used/accounts"))
221 .respond_with(ResponseTemplate::new(200).set_body_json(account_list_fixture()))
222 .expect(1)
223 .mount(&server)
224 .await;
225
226 let (accounts, _) = client.get_accounts(PlanId::LastUsed).send().await.unwrap();
227 assert_eq!(accounts.len(), 2);
228 assert!(
229 accounts
230 .iter()
231 .zip([TEST_ID_1, TEST_ID_1])
232 .all(|(a, id)| a.id.to_string() == id)
233 );
234 }
235
236 #[tokio::test]
237 async fn get_account_returns_id() {
238 let (client, server) = new_test_client().await;
239
240 Mock::given(method("GET"))
241 .and(path(format!("/plans/last-used/accounts/{}", TEST_ID_1)))
242 .respond_with(ResponseTemplate::new(200).set_body_json(account_single_fixture()))
243 .expect(1)
244 .mount(&server)
245 .await;
246
247 let account = client
248 .get_account(PlanId::LastUsed, uuid!(TEST_ID_1))
249 .await
250 .unwrap();
251 assert_eq!(account.id.to_string(), TEST_ID_1);
252 }
253
254 #[tokio::test]
255 async fn get_account_returns_not_found() {
256 let (client, server) = new_test_client().await;
257
258 Mock::given(method("GET"))
259 .and(path(format!("/plans/last-used/accounts/{}", TEST_ID_1)))
260 .respond_with(ResponseTemplate::new(404).set_body_json(error_body(
261 "404",
262 "not_found",
263 "Account not found",
264 )))
265 .mount(&server)
266 .await;
267
268 let err = client
269 .get_account(PlanId::LastUsed, TEST_ID_1.parse().unwrap())
270 .await
271 .unwrap_err();
272 assert!(matches!(err, Error::NotFound(_)));
273 }
274
275 #[tokio::test]
276 async fn create_account_succeeds() {
277 let (client, server) = new_test_client().await;
278
279 let input_account = account_fixture();
280 let account = SaveAccount {
281 name: input_account["name"].as_str().unwrap().to_string(),
282 acct_type: SaveAccountType::try_from(input_account["type"].as_str().unwrap()).unwrap(),
283 balance: input_account["balance"].as_i64().unwrap(),
284 };
285
286 Mock::given(method("POST"))
287 .and(path(format!("/plans/{}/accounts", TEST_ID_1)))
288 .respond_with(ResponseTemplate::new(200).set_body_json(account_single_fixture()))
289 .mount(&server)
290 .await;
291
292 let account_response = client
293 .create_account(PlanId::Id(uuid!(TEST_ID_1)), account)
294 .await
295 .unwrap();
296 assert_eq!(account_response.id.to_string(), TEST_ID_1);
297 assert_eq!(
298 account_response.name,
299 input_account["name"].as_str().unwrap()
300 );
301 assert_eq!(
302 account_response.balance,
303 input_account["balance"].as_i64().unwrap()
304 );
305 assert_eq!(
306 account_response.deleted,
307 input_account["deleted"].as_bool().unwrap()
308 );
309 }
310
311 #[tokio::test]
312 async fn create_account_returns_bad_request() {
313 let (client, server) = new_test_client().await;
314
315 Mock::given(method("POST"))
316 .and(path(format!("/plans/{}/accounts", TEST_ID_1)))
317 .respond_with(ResponseTemplate::new(400).set_body_json(error_body(
318 "400",
319 "bad_request",
320 "Bad Request",
321 )))
322 .mount(&server)
323 .await;
324
325 let account = SaveAccount {
326 name: "A bad bad name".to_string(),
327 acct_type: SaveAccountType::Cash,
328 balance: -500,
329 };
330 let err = client
331 .create_account(PlanId::Id(uuid!(TEST_ID_1)), account)
332 .await
333 .unwrap_err();
334 assert!(matches!(err, Error::BadRequest(_)));
335 }
336
337 #[tokio::test]
338 async fn create_account_returns_conflict() {
339 let (client, server) = new_test_client().await;
340
341 Mock::given(method("POST"))
342 .and(path(format!("/plans/{}/accounts", TEST_ID_1)))
343 .respond_with(
344 ResponseTemplate::new(409).set_body_json(error_body("409", "conflict", "Conflict")),
345 )
346 .mount(&server)
347 .await;
348
349 let account = SaveAccount {
350 name: "A conflicting conflicting name".to_string(),
351 acct_type: SaveAccountType::Cash,
352 balance: -500,
353 };
354 let err = client
355 .create_account(PlanId::Id(uuid!(TEST_ID_1)), account)
356 .await
357 .unwrap_err();
358 assert!(matches!(err, Error::Conflict(_)));
359 }
360}