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