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