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, error::ServerError, 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// 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.
104command! {
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}
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).
142command! {
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            .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}
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.
170fn 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)]
176mod 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    async fn setup() {
193        CONTEXT
194            .get_or_init(|| async {
195                #[cfg(feature = "testlog")]
196                let _ = env_logger::builder()
197                    .is_test(true)
198                    .filter_level(log::LevelFilter::Trace)
199                    .try_init();
200            })
201            .await;
202        USER.get_or_init(|| async { User { id: Uuid::new_v4() } })
203            .await;
204    }
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                .filter_map(|(entity, _)| {
450                    if let FinanceEntity::Split(s) = entity {
451                        Some(s.id)
452                    } else {
453                        None
454                    }
455                })
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}