Skip to main content

server/command/
split.rs

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        // Ensure at least one filter is provided
25        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        // Execute the appropriate query based on provided filters
32        let splits = match (account, transaction) {
33            (Some(account_id), Some(tx_id)) => {
34                // Filter by both account and transaction
35                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                // Filter by account only
46                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                // Filter by transaction only
56                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!(), // We already checked this case above
65        };
66
67        // Convert Split objects to tagged entities
68        let mut split_entities = Vec::new();
69        for split in splits {
70            // Get tags for this split
71            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    /// Context for keeping environment intact
115    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        // First create a commodity
140        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        // Get the commodity ID
148        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        // Create two accounts
155        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        // Create a transaction between the accounts
180        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        // List splits for account1
221        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        // List splits for account2
243        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        // List splits for non-existent account
265        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}