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
            .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
2
                .filter_map(|(entity, _)| {
376
2
                    if let FinanceEntity::Split(s) = entity {
377
2
                        Some(s.id)
378
                    } else {
379
                        None
380
                    }
381
2
                })
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
}