1use finance::{split::Split, tag::Tag};
2use sqlx::types::Uuid;
3use std::{collections::HashMap, fmt::Debug};
4use supp_macro::command;
5
6use super::{CmdError, CmdResult};
7use crate::{command::FinanceEntity, config::ConfigError, error::ServerError, user::User};
8
9command! {
10 ListSplits {
11 #[required]
12 user_id: Uuid,
13 #[optional]
14 account: Uuid,
15 #[optional]
16 transaction: Uuid,
17 } => {
18 let user = User { id: user_id };
19 let mut conn = user.get_connection().await.map_err(|err| {
20 log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
21 ConfigError::DB
22 })?;
23
24 if account.is_none() && transaction.is_none() {
26 return Err(CmdError::Args(
27 "At least one filter (account or transaction) is required".to_string(),
28 ));
29 }
30
31 let splits = match (account, transaction) {
33 (Some(account_id), Some(tx_id)) => {
34 sqlx::query_file_as!(
36 Split,
37 "sql/select/splits/by_account_and_transaction.sql",
38 account_id,
39 tx_id
40 )
41 .fetch_all(&mut *conn)
42 .await?
43 }
44 (Some(account_id), None) => {
45 sqlx::query_file_as!(
47 Split,
48 "sql/select/splits/by_account.sql",
49 account_id
50 )
51 .fetch_all(&mut *conn)
52 .await?
53 }
54 (None, Some(tx_id)) => {
55 sqlx::query_file_as!(
57 Split,
58 "sql/select/splits/by_transaction.sql",
59 tx_id
60 )
61 .fetch_all(&mut *conn)
62 .await?
63 }
64 _ => unreachable!(), };
66
67 let mut split_entities = Vec::new();
69 for split in splits {
70 let tags: HashMap<String, FinanceEntity> = sqlx::query_file!(
72 "sql/select/tags/by_split.sql",
73 split.id
74 )
75 .fetch_all(&mut *conn)
76 .await?
77 .into_iter()
78 .map(|row| {
79 (
80 row.tag_name.clone(),
81 FinanceEntity::Tag(Tag {
82 id: row.id,
83 tag_name: row.tag_name,
84 tag_value: row.tag_value,
85 description: row.description,
86 }),
87 )
88 })
89 .collect();
90
91 split_entities.push((FinanceEntity::Split(split), tags));
92 }
93
94 Ok(Some(CmdResult::TaggedEntities {
95 entities: split_entities,
96 pagination: None,
97 }))
98 }
99}
100
101command! {
105 SetSplitTag {
106 #[required]
107 user_id: Uuid,
108 #[required]
109 split_id: Uuid,
110 #[required]
111 tag_name: String,
112 #[required]
113 tag_value: String,
114 #[optional]
115 description: String,
116 } => {
117 let user = User { id: user_id };
118 let desc = description.and_then(|text| {
119 if text.trim().is_empty() {
120 None
121 } else {
122 Some(text)
123 }
124 });
125 let tag = Tag {
126 id: Uuid::new_v4(),
127 tag_name,
128 tag_value,
129 description: desc,
130 };
131 user.set_split_tag(split_id, &tag)
132 .await
133 .map_err(map_server_err)?;
134 Ok(Some(CmdResult::String("ok".to_string())))
135 }
136}
137
138command! {
143 GetSplitTag {
144 #[required]
145 user_id: Uuid,
146 #[required]
147 split_id: Uuid,
148 #[required]
149 tag_name: String,
150 } => {
151 let user = User { id: user_id };
152 let tags = user
153 .get_split_tags(split_id)
154 .await
155 .map_err(map_server_err)?;
156 let value = tags
157 .into_iter()
158 .find(|t| t.tag_name == tag_name)
159 .map(|t| t.tag_value)
160 .unwrap_or_default();
161 Ok(Some(CmdResult::String(value)))
162 }
163}
164
165fn map_server_err(err: ServerError) -> CmdError {
171 log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
172 CmdError::Args(format!("{err:?}"))
173}
174
175#[cfg(test)]
176mod command_tests {
177 use super::*;
178 use crate::{
179 command::{
180 account::CreateAccount, commodity::CreateCommodity, transaction::CreateTransaction,
181 },
182 db::DB_POOL,
183 };
184 use sqlx::{PgPool, types::chrono::Utc};
185 use supp_macro::local_db_sqlx_test;
186 use tokio::sync::OnceCell;
187
188 static CONTEXT: OnceCell<()> = OnceCell::const_new();
190 static USER: OnceCell<User> = OnceCell::const_new();
191
192 async fn setup() {
193 CONTEXT
194 .get_or_init(|| async {
195 #[cfg(feature = "testlog")]
196 let _ = env_logger::builder()
197 .is_test(true)
198 .filter_level(log::LevelFilter::Trace)
199 .try_init();
200 })
201 .await;
202 USER.get_or_init(|| async { User { id: Uuid::new_v4() } })
203 .await;
204 }
205
206 #[local_db_sqlx_test]
207 async fn test_list_splits(pool: PgPool) -> anyhow::Result<()> {
208 let user = USER.get().unwrap();
209 user.commit()
210 .await
211 .expect("Failed to commit user to database");
212
213 let commodity_result = CreateCommodity::new()
215 .symbol("TST".to_string())
216 .name("Test Commodity".to_string())
217 .user_id(user.id)
218 .run()
219 .await?;
220
221 let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
223 uuid::Uuid::parse_str(&id)?
224 } else {
225 panic!("Expected commodity ID string result");
226 };
227
228 let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
230 CreateAccount::new()
231 .name("Account 1".to_string())
232 .user_id(user.id)
233 .run()
234 .await?
235 {
236 account
237 } else {
238 panic!("Expected account entity result");
239 };
240
241 let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
242 CreateAccount::new()
243 .name("Account 2".to_string())
244 .user_id(user.id)
245 .run()
246 .await?
247 {
248 account
249 } else {
250 panic!("Expected account entity result");
251 };
252
253 let tx_id = Uuid::new_v4();
255 let now = Utc::now();
256
257 let split1_id = Uuid::new_v4();
258 let split1 = Split {
259 id: split1_id,
260 tx_id,
261 account_id: account1.id,
262 commodity_id,
263 value_num: -100,
264 value_denom: 1,
265 reconcile_state: None,
266 reconcile_date: None,
267 lot_id: None,
268 };
269
270 let split2_id = Uuid::new_v4();
271 let split2 = Split {
272 id: split2_id,
273 tx_id,
274 account_id: account2.id,
275 commodity_id,
276 value_num: 100,
277 value_denom: 1,
278 reconcile_state: None,
279 reconcile_date: None,
280 lot_id: None,
281 };
282
283 let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
284 CreateTransaction::new()
285 .user_id(user.id)
286 .splits(splits)
287 .id(tx_id)
288 .post_date(now)
289 .enter_date(now)
290 .note("Test transaction".to_string())
291 .run()
292 .await?;
293
294 if let Some(CmdResult::TaggedEntities { entities, .. }) = ListSplits::new()
296 .user_id(user.id)
297 .account(account1.id)
298 .run()
299 .await?
300 {
301 assert_eq!(entities.len(), 1, "Expected one split for account1");
302
303 let (entity, _tags) = &entities[0];
304 if let FinanceEntity::Split(split) = entity {
305 assert_eq!(split.id, split1_id);
306 assert_eq!(split.account_id, account1.id);
307 assert_eq!(split.value_num, -100);
308 assert_eq!(split.value_denom, 1);
309 } else {
310 panic!("Expected Split entity");
311 }
312 } else {
313 panic!("Expected TaggedEntities result");
314 }
315
316 if let Some(CmdResult::TaggedEntities { entities, .. }) = ListSplits::new()
318 .user_id(user.id)
319 .account(account2.id)
320 .run()
321 .await?
322 {
323 assert_eq!(entities.len(), 1, "Expected one split for account2");
324
325 let (entity, _tags) = &entities[0];
326 if let FinanceEntity::Split(split) = entity {
327 assert_eq!(split.id, split2_id);
328 assert_eq!(split.account_id, account2.id);
329 assert_eq!(split.value_num, 100);
330 assert_eq!(split.value_denom, 1);
331 } else {
332 panic!("Expected Split entity");
333 }
334 } else {
335 panic!("Expected TaggedEntities result");
336 }
337
338 if let Some(CmdResult::TaggedEntities { entities, .. }) = ListSplits::new()
340 .user_id(user.id)
341 .account(Uuid::new_v4())
342 .run()
343 .await?
344 {
345 assert_eq!(
346 entities.len(),
347 0,
348 "Expected no splits for non-existent account"
349 );
350 } else {
351 panic!("Expected TaggedEntities result");
352 }
353 }
354
355 #[local_db_sqlx_test]
356 async fn test_list_splits_by_transaction(pool: PgPool) -> anyhow::Result<()> {
357 let user = USER.get().unwrap();
358 user.commit()
359 .await
360 .expect("Failed to commit user to database");
361
362 let commodity_result = CreateCommodity::new()
363 .symbol("TRX".to_string())
364 .name("Transaction Test Commodity".to_string())
365 .user_id(user.id)
366 .run()
367 .await?;
368
369 let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
370 uuid::Uuid::parse_str(&id)?
371 } else {
372 panic!("Expected commodity ID string result");
373 };
374
375 let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
376 CreateAccount::new()
377 .name("TxTest Account 1".to_string())
378 .user_id(user.id)
379 .run()
380 .await?
381 {
382 account
383 } else {
384 panic!("Expected account entity result");
385 };
386
387 let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
388 CreateAccount::new()
389 .name("TxTest Account 2".to_string())
390 .user_id(user.id)
391 .run()
392 .await?
393 {
394 account
395 } else {
396 panic!("Expected account entity result");
397 };
398
399 let tx_id = Uuid::new_v4();
400 let now = Utc::now();
401
402 let split1_id = Uuid::new_v4();
403 let split1 = Split {
404 id: split1_id,
405 tx_id,
406 account_id: account1.id,
407 commodity_id,
408 value_num: -200,
409 value_denom: 1,
410 reconcile_state: None,
411 reconcile_date: None,
412 lot_id: None,
413 };
414
415 let split2_id = Uuid::new_v4();
416 let split2 = Split {
417 id: split2_id,
418 tx_id,
419 account_id: account2.id,
420 commodity_id,
421 value_num: 200,
422 value_denom: 1,
423 reconcile_state: None,
424 reconcile_date: None,
425 lot_id: None,
426 };
427
428 let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
429 CreateTransaction::new()
430 .user_id(user.id)
431 .splits(splits)
432 .id(tx_id)
433 .post_date(now)
434 .enter_date(now)
435 .note("Transaction test".to_string())
436 .run()
437 .await?;
438
439 if let Some(CmdResult::TaggedEntities { entities, .. }) = ListSplits::new()
440 .user_id(user.id)
441 .transaction(tx_id)
442 .run()
443 .await?
444 {
445 assert_eq!(entities.len(), 2, "Expected two splits for transaction");
446
447 let split_ids: Vec<Uuid> = entities
448 .iter()
449 .filter_map(|(entity, _)| {
450 if let FinanceEntity::Split(s) = entity {
451 Some(s.id)
452 } else {
453 None
454 }
455 })
456 .collect();
457
458 assert!(split_ids.contains(&split1_id));
459 assert!(split_ids.contains(&split2_id));
460 } else {
461 panic!("Expected TaggedEntities result");
462 }
463
464 if let Some(CmdResult::TaggedEntities { entities, .. }) = ListSplits::new()
465 .user_id(user.id)
466 .transaction(Uuid::new_v4())
467 .run()
468 .await?
469 {
470 assert_eq!(
471 entities.len(),
472 0,
473 "Expected no splits for non-existent transaction"
474 );
475 } else {
476 panic!("Expected TaggedEntities result");
477 }
478 }
479}