1
use finance::{split::Split, tag::Tag};
2
use sqlx::types::Uuid;
3
use std::{collections::HashMap, fmt::Debug};
4
use supp_macro::command;
5

            
6
use super::{CmdError, CmdResult};
7
use crate::{command::FinanceEntity, config::ConfigError, user::User};
8

            
9
command! {
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
20
}
100

            
101
#[cfg(test)]
102
mod 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
2
    async fn setup() {
119
2
        CONTEXT
120
2
            .get_or_init(|| async {
121
                #[cfg(feature = "testlog")]
122
1
                let _ = env_logger::builder()
123
1
                    .is_test(true)
124
1
                    .filter_level(log::LevelFilter::Trace)
125
1
                    .try_init();
126
2
            })
127
2
            .await;
128
2
        USER.get_or_init(|| async { User { id: Uuid::new_v4() } })
129
2
            .await;
130
2
    }
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
            .fraction(1.into())
142
            .symbol("TST".to_string())
143
            .name("Test Commodity".to_string())
144
            .user_id(user.id)
145
            .run()
146
            .await?;
147

            
148
        // Get the commodity ID
149
        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
150
            uuid::Uuid::parse_str(&id)?
151
        } else {
152
            panic!("Expected commodity ID string result");
153
        };
154

            
155
        // Create two accounts
156
        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
157
            CreateAccount::new()
158
                .name("Account 1".to_string())
159
                .user_id(user.id)
160
                .run()
161
                .await?
162
        {
163
            account
164
        } else {
165
            panic!("Expected account entity result");
166
        };
167

            
168
        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
169
            CreateAccount::new()
170
                .name("Account 2".to_string())
171
                .user_id(user.id)
172
                .run()
173
                .await?
174
        {
175
            account
176
        } else {
177
            panic!("Expected account entity result");
178
        };
179

            
180
        // Create a transaction between the accounts
181
        let tx_id = Uuid::new_v4();
182
        let now = Utc::now();
183

            
184
        let split1_id = Uuid::new_v4();
185
        let split1 = Split {
186
            id: split1_id,
187
            tx_id,
188
            account_id: account1.id,
189
            commodity_id,
190
            value_num: -100,
191
            value_denom: 1,
192
            reconcile_state: None,
193
            reconcile_date: None,
194
            lot_id: None,
195
        };
196

            
197
        let split2_id = Uuid::new_v4();
198
        let split2 = Split {
199
            id: split2_id,
200
            tx_id,
201
            account_id: account2.id,
202
            commodity_id,
203
            value_num: 100,
204
            value_denom: 1,
205
            reconcile_state: None,
206
            reconcile_date: None,
207
            lot_id: None,
208
        };
209

            
210
        let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
211
        CreateTransaction::new()
212
            .user_id(user.id)
213
            .splits(splits)
214
            .id(tx_id)
215
            .post_date(now)
216
            .enter_date(now)
217
            .note("Test transaction".to_string())
218
            .run()
219
            .await?;
220

            
221
        // List splits for account1
222
        if let Some(CmdResult::TaggedEntities { entities, .. }) = ListSplits::new()
223
            .user_id(user.id)
224
            .account(account1.id)
225
            .run()
226
            .await?
227
        {
228
            assert_eq!(entities.len(), 1, "Expected one split for account1");
229

            
230
            let (entity, _tags) = &entities[0];
231
            if let FinanceEntity::Split(split) = entity {
232
                assert_eq!(split.id, split1_id);
233
                assert_eq!(split.account_id, account1.id);
234
                assert_eq!(split.value_num, -100);
235
                assert_eq!(split.value_denom, 1);
236
            } else {
237
                panic!("Expected Split entity");
238
            }
239
        } else {
240
            panic!("Expected TaggedEntities result");
241
        }
242

            
243
        // List splits for account2
244
        if let Some(CmdResult::TaggedEntities { entities, .. }) = ListSplits::new()
245
            .user_id(user.id)
246
            .account(account2.id)
247
            .run()
248
            .await?
249
        {
250
            assert_eq!(entities.len(), 1, "Expected one split for account2");
251

            
252
            let (entity, _tags) = &entities[0];
253
            if let FinanceEntity::Split(split) = entity {
254
                assert_eq!(split.id, split2_id);
255
                assert_eq!(split.account_id, account2.id);
256
                assert_eq!(split.value_num, 100);
257
                assert_eq!(split.value_denom, 1);
258
            } else {
259
                panic!("Expected Split entity");
260
            }
261
        } else {
262
            panic!("Expected TaggedEntities result");
263
        }
264

            
265
        // List splits for non-existent account
266
        if let Some(CmdResult::TaggedEntities { entities, .. }) = ListSplits::new()
267
            .user_id(user.id)
268
            .account(Uuid::new_v4())
269
            .run()
270
            .await?
271
        {
272
            assert_eq!(
273
                entities.len(),
274
                0,
275
                "Expected no splits for non-existent account"
276
            );
277
        } else {
278
            panic!("Expected TaggedEntities result");
279
        }
280
    }
281

            
282
    #[local_db_sqlx_test]
283
    async fn test_list_splits_by_transaction(pool: PgPool) -> anyhow::Result<()> {
284
        let user = USER.get().unwrap();
285
        user.commit()
286
            .await
287
            .expect("Failed to commit user to database");
288

            
289
        let commodity_result = CreateCommodity::new()
290
            .fraction(1.into())
291
            .symbol("TRX".to_string())
292
            .name("Transaction Test Commodity".to_string())
293
            .user_id(user.id)
294
            .run()
295
            .await?;
296

            
297
        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
298
            uuid::Uuid::parse_str(&id)?
299
        } else {
300
            panic!("Expected commodity ID string result");
301
        };
302

            
303
        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
304
            CreateAccount::new()
305
                .name("TxTest Account 1".to_string())
306
                .user_id(user.id)
307
                .run()
308
                .await?
309
        {
310
            account
311
        } else {
312
            panic!("Expected account entity result");
313
        };
314

            
315
        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
316
            CreateAccount::new()
317
                .name("TxTest Account 2".to_string())
318
                .user_id(user.id)
319
                .run()
320
                .await?
321
        {
322
            account
323
        } else {
324
            panic!("Expected account entity result");
325
        };
326

            
327
        let tx_id = Uuid::new_v4();
328
        let now = Utc::now();
329

            
330
        let split1_id = Uuid::new_v4();
331
        let split1 = Split {
332
            id: split1_id,
333
            tx_id,
334
            account_id: account1.id,
335
            commodity_id,
336
            value_num: -200,
337
            value_denom: 1,
338
            reconcile_state: None,
339
            reconcile_date: None,
340
            lot_id: None,
341
        };
342

            
343
        let split2_id = Uuid::new_v4();
344
        let split2 = Split {
345
            id: split2_id,
346
            tx_id,
347
            account_id: account2.id,
348
            commodity_id,
349
            value_num: 200,
350
            value_denom: 1,
351
            reconcile_state: None,
352
            reconcile_date: None,
353
            lot_id: None,
354
        };
355

            
356
        let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
357
        CreateTransaction::new()
358
            .user_id(user.id)
359
            .splits(splits)
360
            .id(tx_id)
361
            .post_date(now)
362
            .enter_date(now)
363
            .note("Transaction test".to_string())
364
            .run()
365
            .await?;
366

            
367
        if let Some(CmdResult::TaggedEntities { entities, .. }) = ListSplits::new()
368
            .user_id(user.id)
369
            .transaction(tx_id)
370
            .run()
371
            .await?
372
        {
373
            assert_eq!(entities.len(), 2, "Expected two splits for transaction");
374

            
375
            let split_ids: Vec<Uuid> = entities
376
                .iter()
377
2
                .filter_map(|(entity, _)| {
378
2
                    if let FinanceEntity::Split(s) = entity {
379
2
                        Some(s.id)
380
                    } else {
381
                        None
382
                    }
383
2
                })
384
                .collect();
385

            
386
            assert!(split_ids.contains(&split1_id));
387
            assert!(split_ids.contains(&split2_id));
388
        } else {
389
            panic!("Expected TaggedEntities result");
390
        }
391

            
392
        if let Some(CmdResult::TaggedEntities { entities, .. }) = ListSplits::new()
393
            .user_id(user.id)
394
            .transaction(Uuid::new_v4())
395
            .run()
396
            .await?
397
        {
398
            assert_eq!(
399
                entities.len(),
400
                0,
401
                "Expected no splits for non-existent transaction"
402
            );
403
        } else {
404
            panic!("Expected TaggedEntities result");
405
        }
406
    }
407
}