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, error::ServerError, 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
30
            .map(|row| {
79
30
                (
80
30
                    row.tag_name.clone(),
81
30
                    FinanceEntity::Tag(Tag {
82
30
                        id: row.id,
83
30
                        tag_name: row.tag_name,
84
30
                        tag_value: row.tag_value,
85
30
                        description: row.description,
86
30
                    }),
87
30
                )
88
30
            })
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
860
}
100

            
101
// Idempotent set: replace any existing (split, tag_name) link with
102
// `tag_value`. Mirrors `SetAccountTag` so script-side `set-split-tag`
103
// reads as the obvious analog.
104
command! {
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
1050
}
137

            
138
// Looks up a single tag value by name on a split. Returns the
139
// `tag_value` as `CmdResult::String("")` when the tag isn't set —
140
// keeps the wasm contract simple (`string` return type, no `Option`
141
// boxing).
142
command! {
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
25
            .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
870
}
164

            
165
/// Bridge from `ServerError` to the command-layer `CmdError`. The
166
/// underlying tag-CRUD helpers live in `crate::tag::user` and surface
167
/// failures as `ServerError`; commands need `CmdError` to flow into
168
/// `command!`'s `Result<...>`. Pulled out so SetSplitTag and
169
/// GetSplitTag don't duplicate the conversion boilerplate.
170
fn 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)]
176
mod 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
    /// Context for keeping environment intact
189
    static CONTEXT: OnceCell<()> = OnceCell::const_new();
190
    static USER: OnceCell<User> = OnceCell::const_new();
191

            
192
2
    async fn setup() {
193
2
        CONTEXT
194
2
            .get_or_init(|| async {
195
                #[cfg(feature = "testlog")]
196
1
                let _ = env_logger::builder()
197
1
                    .is_test(true)
198
1
                    .filter_level(log::LevelFilter::Trace)
199
1
                    .try_init();
200
2
            })
201
2
            .await;
202
2
        USER.get_or_init(|| async { User { id: Uuid::new_v4() } })
203
2
            .await;
204
2
    }
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
        // First create a commodity
214
        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
        // Get the commodity ID
222
        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
        // Create two accounts
229
        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
        // Create a transaction between the accounts
254
        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
        // List splits for account1
295
        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
        // List splits for account2
317
        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
        // List splits for non-existent account
339
        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
2
                .filter_map(|(entity, _)| {
450
2
                    if let FinanceEntity::Split(s) = entity {
451
2
                        Some(s.id)
452
                    } else {
453
                        None
454
                    }
455
2
                })
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
}