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