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, 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
101#[cfg(test)]
102mod command_tests {
103 use super::*;
104 use crate::{
105 command::{
106 account::CreateAccount, commodity::CreateCommodity, transaction::CreateTransaction,
107 },
108 db::DB_POOL,
109 };
110 use sqlx::{PgPool, types::chrono::Utc};
111 use supp_macro::local_db_sqlx_test;
112 use tokio::sync::OnceCell;
113
114 static CONTEXT: OnceCell<()> = OnceCell::const_new();
116 static USER: OnceCell<User> = OnceCell::const_new();
117
118 async fn setup() {
119 CONTEXT
120 .get_or_init(|| async {
121 #[cfg(feature = "testlog")]
122 let _ = env_logger::builder()
123 .is_test(true)
124 .filter_level(log::LevelFilter::Trace)
125 .try_init();
126 })
127 .await;
128 USER.get_or_init(|| async { User { id: Uuid::new_v4() } })
129 .await;
130 }
131
132 #[local_db_sqlx_test]
133 async fn test_list_splits(pool: PgPool) -> anyhow::Result<()> {
134 let user = USER.get().unwrap();
135 user.commit()
136 .await
137 .expect("Failed to commit user to database");
138
139 let commodity_result = CreateCommodity::new()
141 .symbol("TST".to_string())
142 .name("Test Commodity".to_string())
143 .user_id(user.id)
144 .run()
145 .await?;
146
147 let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
149 uuid::Uuid::parse_str(&id)?
150 } else {
151 panic!("Expected commodity ID string result");
152 };
153
154 let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
156 CreateAccount::new()
157 .name("Account 1".to_string())
158 .user_id(user.id)
159 .run()
160 .await?
161 {
162 account
163 } else {
164 panic!("Expected account entity result");
165 };
166
167 let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
168 CreateAccount::new()
169 .name("Account 2".to_string())
170 .user_id(user.id)
171 .run()
172 .await?
173 {
174 account
175 } else {
176 panic!("Expected account entity result");
177 };
178
179 let tx_id = Uuid::new_v4();
181 let now = Utc::now();
182
183 let split1_id = Uuid::new_v4();
184 let split1 = Split {
185 id: split1_id,
186 tx_id,
187 account_id: account1.id,
188 commodity_id,
189 value_num: -100,
190 value_denom: 1,
191 reconcile_state: None,
192 reconcile_date: None,
193 lot_id: None,
194 };
195
196 let split2_id = Uuid::new_v4();
197 let split2 = Split {
198 id: split2_id,
199 tx_id,
200 account_id: account2.id,
201 commodity_id,
202 value_num: 100,
203 value_denom: 1,
204 reconcile_state: None,
205 reconcile_date: None,
206 lot_id: None,
207 };
208
209 let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
210 CreateTransaction::new()
211 .user_id(user.id)
212 .splits(splits)
213 .id(tx_id)
214 .post_date(now)
215 .enter_date(now)
216 .note("Test transaction".to_string())
217 .run()
218 .await?;
219
220 if let Some(CmdResult::TaggedEntities { entities, .. }) = ListSplits::new()
222 .user_id(user.id)
223 .account(account1.id)
224 .run()
225 .await?
226 {
227 assert_eq!(entities.len(), 1, "Expected one split for account1");
228
229 let (entity, _tags) = &entities[0];
230 if let FinanceEntity::Split(split) = entity {
231 assert_eq!(split.id, split1_id);
232 assert_eq!(split.account_id, account1.id);
233 assert_eq!(split.value_num, -100);
234 assert_eq!(split.value_denom, 1);
235 } else {
236 panic!("Expected Split entity");
237 }
238 } else {
239 panic!("Expected TaggedEntities result");
240 }
241
242 if let Some(CmdResult::TaggedEntities { entities, .. }) = ListSplits::new()
244 .user_id(user.id)
245 .account(account2.id)
246 .run()
247 .await?
248 {
249 assert_eq!(entities.len(), 1, "Expected one split for account2");
250
251 let (entity, _tags) = &entities[0];
252 if let FinanceEntity::Split(split) = entity {
253 assert_eq!(split.id, split2_id);
254 assert_eq!(split.account_id, account2.id);
255 assert_eq!(split.value_num, 100);
256 assert_eq!(split.value_denom, 1);
257 } else {
258 panic!("Expected Split entity");
259 }
260 } else {
261 panic!("Expected TaggedEntities result");
262 }
263
264 if let Some(CmdResult::TaggedEntities { entities, .. }) = ListSplits::new()
266 .user_id(user.id)
267 .account(Uuid::new_v4())
268 .run()
269 .await?
270 {
271 assert_eq!(
272 entities.len(),
273 0,
274 "Expected no splits for non-existent account"
275 );
276 } else {
277 panic!("Expected TaggedEntities result");
278 }
279 }
280
281 #[local_db_sqlx_test]
282 async fn test_list_splits_by_transaction(pool: PgPool) -> anyhow::Result<()> {
283 let user = USER.get().unwrap();
284 user.commit()
285 .await
286 .expect("Failed to commit user to database");
287
288 let commodity_result = CreateCommodity::new()
289 .symbol("TRX".to_string())
290 .name("Transaction Test Commodity".to_string())
291 .user_id(user.id)
292 .run()
293 .await?;
294
295 let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
296 uuid::Uuid::parse_str(&id)?
297 } else {
298 panic!("Expected commodity ID string result");
299 };
300
301 let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
302 CreateAccount::new()
303 .name("TxTest Account 1".to_string())
304 .user_id(user.id)
305 .run()
306 .await?
307 {
308 account
309 } else {
310 panic!("Expected account entity result");
311 };
312
313 let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
314 CreateAccount::new()
315 .name("TxTest Account 2".to_string())
316 .user_id(user.id)
317 .run()
318 .await?
319 {
320 account
321 } else {
322 panic!("Expected account entity result");
323 };
324
325 let tx_id = Uuid::new_v4();
326 let now = Utc::now();
327
328 let split1_id = Uuid::new_v4();
329 let split1 = Split {
330 id: split1_id,
331 tx_id,
332 account_id: account1.id,
333 commodity_id,
334 value_num: -200,
335 value_denom: 1,
336 reconcile_state: None,
337 reconcile_date: None,
338 lot_id: None,
339 };
340
341 let split2_id = Uuid::new_v4();
342 let split2 = Split {
343 id: split2_id,
344 tx_id,
345 account_id: account2.id,
346 commodity_id,
347 value_num: 200,
348 value_denom: 1,
349 reconcile_state: None,
350 reconcile_date: None,
351 lot_id: None,
352 };
353
354 let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
355 CreateTransaction::new()
356 .user_id(user.id)
357 .splits(splits)
358 .id(tx_id)
359 .post_date(now)
360 .enter_date(now)
361 .note("Transaction test".to_string())
362 .run()
363 .await?;
364
365 if let Some(CmdResult::TaggedEntities { entities, .. }) = ListSplits::new()
366 .user_id(user.id)
367 .transaction(tx_id)
368 .run()
369 .await?
370 {
371 assert_eq!(entities.len(), 2, "Expected two splits for transaction");
372
373 let split_ids: Vec<Uuid> = entities
374 .iter()
375 .filter_map(|(entity, _)| {
376 if let FinanceEntity::Split(s) = entity {
377 Some(s.id)
378 } else {
379 None
380 }
381 })
382 .collect();
383
384 assert!(split_ids.contains(&split1_id));
385 assert!(split_ids.contains(&split2_id));
386 } else {
387 panic!("Expected TaggedEntities result");
388 }
389
390 if let Some(CmdResult::TaggedEntities { entities, .. }) = ListSplits::new()
391 .user_id(user.id)
392 .transaction(Uuid::new_v4())
393 .run()
394 .await?
395 {
396 assert_eq!(
397 entities.len(),
398 0,
399 "Expected no splits for non-existent transaction"
400 );
401 } else {
402 panic!("Expected TaggedEntities result");
403 }
404 }
405}