Skip to main content

server/
tag.rs

1pub mod user {
2
3    use crate::error::ServerError;
4    use crate::user::User;
5    use finance::tag::Tag;
6    use sqlx::types::Uuid;
7
8    /// Which join table to scope `list_tag_names` / `list_tag_values_for` to.
9    #[derive(Debug, Clone, Copy)]
10    pub enum TagScope {
11        Transaction,
12        Account,
13        Split,
14    }
15
16    impl User {
17        pub async fn create_tag(
18            &self,
19            name: String,
20            value: String,
21            description: Option<String>,
22        ) -> Result<Uuid, ServerError> {
23            if name.trim().is_empty() || value.trim().is_empty() {
24                return Err(ServerError::Creation);
25            }
26            let mut conn = self.get_connection().await.map_err(|err| {
27                log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
28                ServerError::DB(err)
29            })?;
30
31            Tag {
32                id: Uuid::new_v4(),
33                tag_name: name,
34                tag_value: value,
35                description,
36            }
37            .commit(&mut *conn)
38            .await
39            .map_err(|err| {
40                log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
41                ServerError::Finance(err)
42            })
43        }
44
45        pub async fn list_tags(&self) -> Result<Vec<Tag>, ServerError> {
46            let mut conn = self.get_connection().await?;
47
48            let tags = sqlx::query_file_as!(Tag, "sql/select/tags/all.sql")
49                .fetch_all(&mut *conn)
50                .await
51                .map_err(|err| {
52                    log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
53                    ServerError::DB(crate::db::DBError::Sqlx(err))
54                })?;
55
56            Ok(tags)
57        }
58
59        pub async fn get_tag(&self, id: Uuid) -> Result<Tag, ServerError> {
60            let mut conn = self.get_connection().await?;
61
62            let tag = sqlx::query_file_as!(Tag, "sql/select/tags/by_id.sql", &id)
63                .fetch_one(&mut *conn)
64                .await
65                .map_err(|err| {
66                    log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
67                    ServerError::DB(crate::db::DBError::Sqlx(err))
68                })?;
69
70            Ok(tag)
71        }
72
73        pub async fn update_tag(
74            &self,
75            id: Uuid,
76            name: String,
77            value: String,
78            description: Option<String>,
79        ) -> Result<(), ServerError> {
80            let mut conn = self.get_connection().await?;
81
82            sqlx::query_file!(
83                "sql/update/tags/update.sql",
84                &id,
85                &name,
86                &value,
87                description.as_deref()
88            )
89            .execute(&mut *conn)
90            .await
91            .map_err(|err| {
92                log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
93                ServerError::DB(crate::db::DBError::Sqlx(err))
94            })?;
95
96            Ok(())
97        }
98
99        pub async fn get_transaction_tags(&self, tx_id: Uuid) -> Result<Vec<Tag>, ServerError> {
100            let mut conn = self.get_connection().await?;
101
102            let tags = sqlx::query_file_as!(Tag, "sql/select/tags/by_transaction.sql", &tx_id)
103                .fetch_all(&mut *conn)
104                .await
105                .map_err(|err| {
106                    log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
107                    ServerError::DB(crate::db::DBError::Sqlx(err))
108                })?;
109
110            Ok(tags)
111        }
112
113        pub async fn create_transaction_tag(
114            &self,
115            tx_id: Uuid,
116            name: String,
117            value: String,
118            description: Option<String>,
119        ) -> Result<Uuid, ServerError> {
120            let tag_id = self.create_tag(name, value, description).await?;
121            let mut conn = self.get_connection().await?;
122            sqlx::query_file!(
123                "sql/insert/transaction_tags/transaction_tag.sql",
124                &tx_id,
125                &tag_id
126            )
127            .execute(&mut *conn)
128            .await
129            .map_err(|err| {
130                log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
131                ServerError::DB(crate::db::DBError::Sqlx(err))
132            })?;
133            Ok(tag_id)
134        }
135
136        /// Cascade-delete a tag: detach from every join table, then drop the
137        /// row from `tags`. Used by user-initiated tag deletion in the UI;
138        /// see `detach_*_tag` + `cleanup_orphan_tag` for the
139        /// "remove from one entity, drop only if no longer referenced"
140        /// pattern.
141        pub async fn delete_tag(&self, id: Uuid) -> Result<(), ServerError> {
142            use sqlx::Connection;
143            let mut conn = self.get_connection().await?;
144            let mut tx = conn.begin().await.map_err(|err| {
145                log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
146                ServerError::DB(crate::db::DBError::Sqlx(err))
147            })?;
148            for sql in [
149                "DELETE FROM transaction_tags WHERE tag_id = $1",
150                "DELETE FROM split_tags WHERE tag_id = $1",
151                "DELETE FROM account_tags WHERE tag_id = $1",
152                "DELETE FROM budget_tags WHERE tag_id = $1",
153                "DELETE FROM commodity_tags WHERE tag_id = $1",
154                "DELETE FROM price_tags WHERE tag_id = $1",
155                "DELETE FROM book_tags WHERE tag_id = $1",
156                "DELETE FROM script_tags WHERE tag_id = $1",
157                "DELETE FROM tag_tags WHERE tagged_tag_id = $1 OR tagging_tag_id = $1",
158                "DELETE FROM tags WHERE id = $1",
159            ] {
160                sqlx::query(sql)
161                    .bind(id)
162                    .execute(&mut *tx)
163                    .await
164                    .map_err(|err| {
165                        log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
166                        ServerError::DB(crate::db::DBError::Sqlx(err))
167                    })?;
168            }
169            tx.commit().await.map_err(|err| {
170                log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
171                ServerError::DB(crate::db::DBError::Sqlx(err))
172            })
173        }
174
175        pub async fn detach_transaction_tag(
176            &self,
177            tx_id: Uuid,
178            tag_id: Uuid,
179        ) -> Result<(), ServerError> {
180            let mut conn = self.get_connection().await?;
181            sqlx::query_file!("sql/delete/transaction_tags/by_pair.sql", &tx_id, &tag_id)
182                .execute(&mut *conn)
183                .await
184                .map_err(|err| {
185                    log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
186                    ServerError::DB(crate::db::DBError::Sqlx(err))
187                })?;
188            Ok(())
189        }
190
191        pub async fn detach_split_tag(
192            &self,
193            split_id: Uuid,
194            tag_id: Uuid,
195        ) -> Result<(), ServerError> {
196            let mut conn = self.get_connection().await?;
197            sqlx::query_file!("sql/delete/split_tags/by_pair.sql", &split_id, &tag_id)
198                .execute(&mut *conn)
199                .await
200                .map_err(|err| {
201                    log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
202                    ServerError::DB(crate::db::DBError::Sqlx(err))
203                })?;
204            Ok(())
205        }
206
207        pub async fn detach_account_tag(
208            &self,
209            account_id: Uuid,
210            tag_id: Uuid,
211        ) -> Result<(), ServerError> {
212            let mut conn = self.get_connection().await?;
213            sqlx::query_file!("sql/delete/account_tags/by_pair.sql", &account_id, &tag_id)
214                .execute(&mut *conn)
215                .await
216                .map_err(|err| {
217                    log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
218                    ServerError::DB(crate::db::DBError::Sqlx(err))
219                })?;
220            Ok(())
221        }
222
223        pub async fn detach_commodity_tag(
224            &self,
225            commodity_id: Uuid,
226            tag_id: Uuid,
227        ) -> Result<(), ServerError> {
228            let mut conn = self.get_connection().await?;
229            sqlx::query_file!(
230                "sql/delete/commodity_tags/by_pair.sql",
231                &commodity_id,
232                &tag_id
233            )
234            .execute(&mut *conn)
235            .await
236            .map_err(|err| {
237                log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
238                ServerError::DB(crate::db::DBError::Sqlx(err))
239            })?;
240            Ok(())
241        }
242
243        /// If `tag_id` is no longer referenced by any join table, delete the
244        /// canonical row. Idempotent.
245        pub async fn cleanup_orphan_tag(&self, tag_id: Uuid) -> Result<(), ServerError> {
246            let mut conn = self.get_connection().await?;
247            let row = sqlx::query_file!("sql/check/tags/is_orphaned.sql", &tag_id)
248                .fetch_one(&mut *conn)
249                .await
250                .map_err(|err| {
251                    log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
252                    ServerError::DB(crate::db::DBError::Sqlx(err))
253                })?;
254            if row.is_orphaned.unwrap_or(false) {
255                sqlx::query_file!("sql/delete/tags/by_id.sql", &tag_id)
256                    .execute(&mut *conn)
257                    .await
258                    .map_err(|err| {
259                        log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
260                        ServerError::DB(crate::db::DBError::Sqlx(err))
261                    })?;
262            }
263            Ok(())
264        }
265
266        pub async fn list_tag_names(&self, scope: TagScope) -> Result<Vec<String>, ServerError> {
267            let mut conn = self.get_connection().await?;
268            let result = match scope {
269                TagScope::Transaction => sqlx::query_file!("sql/select/tags/transaction/names.sql")
270                    .fetch_all(&mut *conn)
271                    .await
272                    .map(|rows| rows.into_iter().map(|r| r.tag_name).collect::<Vec<_>>()),
273                TagScope::Account => sqlx::query_file!("sql/select/tags/account/names.sql")
274                    .fetch_all(&mut *conn)
275                    .await
276                    .map(|rows| rows.into_iter().map(|r| r.tag_name).collect::<Vec<_>>()),
277                TagScope::Split => sqlx::query_file!("sql/select/tags/split/names.sql")
278                    .fetch_all(&mut *conn)
279                    .await
280                    .map(|rows| rows.into_iter().map(|r| r.tag_name).collect::<Vec<_>>()),
281            };
282            result.map_err(|err| {
283                log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
284                ServerError::DB(crate::db::DBError::Sqlx(err))
285            })
286        }
287
288        pub async fn list_tag_values_for(
289            &self,
290            scope: TagScope,
291            tag_name: &str,
292        ) -> Result<Vec<String>, ServerError> {
293            let mut conn = self.get_connection().await?;
294            let result = match scope {
295                TagScope::Transaction => {
296                    sqlx::query_file!("sql/select/tags/transaction/values_by_name.sql", tag_name)
297                        .fetch_all(&mut *conn)
298                        .await
299                        .map(|rows| rows.into_iter().map(|r| r.tag_value).collect::<Vec<_>>())
300                }
301                TagScope::Account => {
302                    sqlx::query_file!("sql/select/tags/account/values_by_name.sql", tag_name)
303                        .fetch_all(&mut *conn)
304                        .await
305                        .map(|rows| rows.into_iter().map(|r| r.tag_value).collect::<Vec<_>>())
306                }
307                TagScope::Split => {
308                    sqlx::query_file!("sql/select/tags/split/values_by_name.sql", tag_name)
309                        .fetch_all(&mut *conn)
310                        .await
311                        .map(|rows| rows.into_iter().map(|r| r.tag_value).collect::<Vec<_>>())
312                }
313            };
314            result.map_err(|err| {
315                log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
316                ServerError::DB(crate::db::DBError::Sqlx(err))
317            })
318        }
319
320        pub async fn list_transaction_tag_names(&self) -> Result<Vec<String>, ServerError> {
321            self.list_tag_names(TagScope::Transaction).await
322        }
323
324        pub async fn list_transaction_tag_values(
325            &self,
326            tag_name: &str,
327        ) -> Result<Vec<String>, ServerError> {
328            self.list_tag_values_for(TagScope::Transaction, tag_name)
329                .await
330        }
331
332        pub async fn create_split_tag(
333            &self,
334            split_id: Uuid,
335            name: String,
336            value: String,
337            description: Option<String>,
338        ) -> Result<Uuid, ServerError> {
339            let tag_id = self.create_tag(name, value, description).await?;
340            let mut conn = self.get_connection().await?;
341            sqlx::query_file!("sql/insert/split_tags/split_tag.sql", &split_id, &tag_id)
342                .execute(&mut *conn)
343                .await
344                .map_err(|err| {
345                    log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
346                    ServerError::DB(crate::db::DBError::Sqlx(err))
347                })?;
348            Ok(tag_id)
349        }
350
351        pub async fn create_account_tag(
352            &self,
353            account_id: Uuid,
354            name: String,
355            value: String,
356            description: Option<String>,
357        ) -> Result<Uuid, ServerError> {
358            let tag_id = self.create_tag(name, value, description).await?;
359            let mut conn = self.get_connection().await?;
360            sqlx::query_file!(
361                "sql/insert/account_tags/account_tag.sql",
362                &account_id,
363                &tag_id
364            )
365            .execute(&mut *conn)
366            .await
367            .map_err(|err| {
368                log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
369                ServerError::DB(crate::db::DBError::Sqlx(err))
370            })?;
371            Ok(tag_id)
372        }
373
374        pub async fn list_account_tag_names(&self) -> Result<Vec<String>, ServerError> {
375            self.list_tag_names(TagScope::Account).await
376        }
377
378        pub async fn list_account_tag_values(
379            &self,
380            tag_name: &str,
381        ) -> Result<Vec<String>, ServerError> {
382            self.list_tag_values_for(TagScope::Account, tag_name).await
383        }
384
385        pub async fn list_split_tag_names(&self) -> Result<Vec<String>, ServerError> {
386            self.list_tag_names(TagScope::Split).await
387        }
388
389        pub async fn list_split_tag_values(
390            &self,
391            tag_name: &str,
392        ) -> Result<Vec<String>, ServerError> {
393            self.list_tag_values_for(TagScope::Split, tag_name).await
394        }
395    }
396
397    #[cfg(test)]
398    mod tag_tests {
399        use super::*;
400        use crate::db::DB_POOL;
401        #[cfg(feature = "testlog")]
402        use env_logger;
403        use finance::{split::Split, transaction::Transaction};
404        #[cfg(feature = "testlog")]
405        use log;
406        use sqlx::PgPool;
407        use sqlx::types::chrono;
408
409        use supp_macro::local_db_sqlx_test;
410        use tokio::sync::OnceCell;
411
412        /// Context for keeping environment intact
413        static CONTEXT: OnceCell<()> = OnceCell::const_new();
414        static USER: OnceCell<User> = OnceCell::const_new();
415
416        async fn setup() {
417            CONTEXT
418                .get_or_init(|| async {
419                    #[cfg(feature = "testlog")]
420                    let _ = env_logger::builder()
421                        .is_test(true)
422                        .filter_level(log::LevelFilter::Trace)
423                        .try_init();
424                })
425                .await;
426            USER.get_or_init(|| async { User { id: Uuid::new_v4() } })
427                .await;
428        }
429
430        #[local_db_sqlx_test]
431        async fn test_tag_creation(pool: PgPool) -> Result<(), anyhow::Error> {
432            let user = USER.get().unwrap();
433            user.commit()
434                .await
435                .expect("Failed to commit user to database");
436
437            let id = user
438                .create_tag("testtag".to_string(), "testval".to_string(), None)
439                .await?;
440
441            let mut conn = user.get_connection().await?;
442            let res = sqlx::query_file!("testdata/query_tag_by_id.sql", &id)
443                .fetch_one(&mut *conn)
444                .await?;
445
446            assert_eq!(res.tag_name, "testtag".to_string());
447            assert_eq!(res.tag_value, "testval".to_string());
448            assert_eq!(res.description, None);
449        }
450
451        #[local_db_sqlx_test]
452        async fn test_tag_creation_with_description(pool: PgPool) -> Result<(), anyhow::Error> {
453            let user = USER.get().unwrap();
454            user.commit()
455                .await
456                .expect("Failed to commit user to database");
457
458            let id = user
459                .create_tag(
460                    "categorytag".to_string(),
461                    "category1".to_string(),
462                    Some("Test description".to_string()),
463                )
464                .await?;
465
466            let tag = user.get_tag(id).await?;
467
468            assert_eq!(tag.tag_name, "categorytag");
469            assert_eq!(tag.tag_value, "category1");
470            assert_eq!(tag.description, Some("Test description".to_string()));
471        }
472
473        #[local_db_sqlx_test]
474        async fn test_list_tags(pool: PgPool) -> Result<(), anyhow::Error> {
475            let user = USER.get().unwrap();
476            user.commit()
477                .await
478                .expect("Failed to commit user to database");
479
480            let id1 = user
481                .create_tag("tag1".to_string(), "value1".to_string(), None)
482                .await?;
483
484            let id2 = user
485                .create_tag(
486                    "tag2".to_string(),
487                    "value2".to_string(),
488                    Some("desc".to_string()),
489                )
490                .await?;
491
492            let tags = user.list_tags().await?;
493
494            assert!(tags.len() >= 2);
495            assert!(tags.iter().any(|t| t.id == id1));
496            assert!(tags.iter().any(|t| t.id == id2));
497
498            let tag1 = tags.iter().find(|t| t.id == id1).unwrap();
499            assert_eq!(tag1.tag_name, "tag1");
500            assert_eq!(tag1.tag_value, "value1");
501            assert_eq!(tag1.description, None);
502
503            let tag2 = tags.iter().find(|t| t.id == id2).unwrap();
504            assert_eq!(tag2.tag_name, "tag2");
505            assert_eq!(tag2.tag_value, "value2");
506            assert_eq!(tag2.description, Some("desc".to_string()));
507        }
508
509        #[local_db_sqlx_test]
510        async fn test_get_tag(pool: PgPool) -> Result<(), anyhow::Error> {
511            let user = USER.get().unwrap();
512            user.commit()
513                .await
514                .expect("Failed to commit user to database");
515
516            let id = user
517                .create_tag(
518                    "gettag".to_string(),
519                    "getvalue".to_string(),
520                    Some("Get description".to_string()),
521                )
522                .await?;
523
524            let tag = user.get_tag(id).await?;
525
526            assert_eq!(tag.id, id);
527            assert_eq!(tag.tag_name, "gettag");
528            assert_eq!(tag.tag_value, "getvalue");
529            assert_eq!(tag.description, Some("Get description".to_string()));
530        }
531
532        #[local_db_sqlx_test]
533        async fn test_get_nonexistent_tag(pool: PgPool) -> Result<(), anyhow::Error> {
534            let user = USER.get().unwrap();
535            user.commit()
536                .await
537                .expect("Failed to commit user to database");
538
539            let nonexistent_id = Uuid::new_v4();
540            let result = user.get_tag(nonexistent_id).await;
541
542            assert!(result.is_err());
543        }
544
545        #[local_db_sqlx_test]
546        async fn test_update_tag(pool: PgPool) -> Result<(), anyhow::Error> {
547            let user = USER.get().unwrap();
548            user.commit()
549                .await
550                .expect("Failed to commit user to database");
551
552            let id = user
553                .create_tag("oldname".to_string(), "oldvalue".to_string(), None)
554                .await?;
555
556            user.update_tag(
557                id,
558                "newname".to_string(),
559                "newvalue".to_string(),
560                Some("Updated description".to_string()),
561            )
562            .await?;
563
564            let tag = user.get_tag(id).await?;
565
566            assert_eq!(tag.id, id);
567            assert_eq!(tag.tag_name, "newname");
568            assert_eq!(tag.tag_value, "newvalue");
569            assert_eq!(tag.description, Some("Updated description".to_string()));
570        }
571
572        #[local_db_sqlx_test]
573        async fn test_update_tag_remove_description(pool: PgPool) {
574            let user = USER.get().unwrap();
575            user.commit()
576                .await
577                .expect("Failed to commit user to database");
578
579            let id = user
580                .create_tag(
581                    "tagname".to_string(),
582                    "tagvalue".to_string(),
583                    Some("Initial description".to_string()),
584                )
585                .await?;
586
587            user.update_tag(id, "tagname".to_string(), "tagvalue".to_string(), None)
588                .await?;
589
590            let tag = user.get_tag(id).await?;
591
592            assert_eq!(tag.description, None);
593        }
594
595        #[local_db_sqlx_test]
596        async fn test_delete_tag(pool: PgPool) -> Result<(), anyhow::Error> {
597            let user = USER.get().unwrap();
598            user.commit()
599                .await
600                .expect("Failed to commit user to database");
601
602            let id = user
603                .create_tag("deletetag".to_string(), "deletevalue".to_string(), None)
604                .await?;
605
606            let tag = user.get_tag(id).await;
607            assert!(tag.is_ok());
608
609            user.delete_tag(id).await?;
610
611            let result = user.get_tag(id).await;
612            assert!(result.is_err());
613        }
614
615        #[local_db_sqlx_test]
616        async fn test_delete_nonexistent_tag(pool: PgPool) -> Result<(), anyhow::Error> {
617            let user = USER.get().unwrap();
618            user.commit()
619                .await
620                .expect("Failed to commit user to database");
621
622            let nonexistent_id = Uuid::new_v4();
623            let result = user.delete_tag(nonexistent_id).await;
624
625            assert!(result.is_ok());
626        }
627
628        #[local_db_sqlx_test]
629        async fn test_list_tags_empty(pool: PgPool) -> Result<(), anyhow::Error> {
630            let user = USER.get().unwrap();
631            user.commit()
632                .await
633                .expect("Failed to commit user to database");
634
635            let tags = user.list_tags().await?;
636
637            assert!(
638                tags.is_empty()
639                    || tags.iter().all(|t| t.tag_name == "name"
640                        || t.tag_name == "note"
641                        || t.tag_name == "symbol")
642            );
643        }
644
645        #[local_db_sqlx_test]
646        async fn test_list_transaction_tag_names_empty(pool: PgPool) -> Result<(), anyhow::Error> {
647            let user = USER.get().unwrap();
648            user.commit()
649                .await
650                .expect("Failed to commit user to database");
651
652            let names = user.list_transaction_tag_names().await?;
653
654            assert!(names.is_empty());
655        }
656
657        #[local_db_sqlx_test]
658        async fn test_list_transaction_tag_names_with_data(
659            pool: PgPool,
660        ) -> Result<(), anyhow::Error> {
661            let user = USER.get().unwrap();
662            user.commit()
663                .await
664                .expect("Failed to commit user to database");
665
666            let commodity_id = user
667                .create_commodity("USD".to_string(), "US Dollar".to_string())
668                .await?
669                .id;
670            let acc1 = user.create_account("test_acc1", None).await?.id;
671            let acc2 = user.create_account("test_acc2", None).await?.id;
672
673            let tx = Transaction {
674                id: sqlx::types::Uuid::new_v4(),
675                post_date: chrono::Utc::now(),
676                enter_date: chrono::Utc::now(),
677            };
678
679            let mut conn = user.get_connection().await?;
680            let mut ticket = tx.enter(&mut *conn).await?;
681            let split1 = Split {
682                id: sqlx::types::Uuid::new_v4(),
683                account_id: acc1,
684                tx_id: tx.id,
685                value_num: 100,
686                value_denom: 1,
687                commodity_id,
688                reconcile_state: None,
689                reconcile_date: None,
690                lot_id: None,
691            };
692            let split2 = Split {
693                id: sqlx::types::Uuid::new_v4(),
694                account_id: acc2,
695                tx_id: tx.id,
696                value_num: -100,
697                value_denom: 1,
698                commodity_id,
699                reconcile_state: None,
700                reconcile_date: None,
701                lot_id: None,
702            };
703            ticket.add_splits(&[&split1, &split2]).await?;
704            ticket.commit().await?;
705
706            user.create_transaction_tag(tx.id, "category".to_string(), "food".to_string(), None)
707                .await?;
708            user.create_transaction_tag(tx.id, "priority".to_string(), "high".to_string(), None)
709                .await?;
710
711            let names = user.list_transaction_tag_names().await?;
712
713            assert_eq!(names.len(), 2);
714            assert!(names.contains(&"category".to_string()));
715            assert!(names.contains(&"priority".to_string()));
716        }
717
718        #[local_db_sqlx_test]
719        async fn test_list_transaction_tag_values_empty(pool: PgPool) -> Result<(), anyhow::Error> {
720            let user = USER.get().unwrap();
721            user.commit()
722                .await
723                .expect("Failed to commit user to database");
724
725            let values = user.list_transaction_tag_values("category").await?;
726
727            assert!(values.is_empty());
728        }
729
730        #[local_db_sqlx_test]
731        async fn test_list_transaction_tag_values_with_data(
732            pool: PgPool,
733        ) -> Result<(), anyhow::Error> {
734            let user = USER.get().unwrap();
735            user.commit()
736                .await
737                .expect("Failed to commit user to database");
738
739            let commodity_id = user
740                .create_commodity("USD".to_string(), "US Dollar".to_string())
741                .await?
742                .id;
743            let acc1 = user.create_account("test_acc1", None).await?.id;
744            let acc2 = user.create_account("test_acc2", None).await?.id;
745
746            let tx1 = Transaction {
747                id: sqlx::types::Uuid::new_v4(),
748                post_date: chrono::Utc::now(),
749                enter_date: chrono::Utc::now(),
750            };
751            let tx2 = Transaction {
752                id: sqlx::types::Uuid::new_v4(),
753                post_date: chrono::Utc::now(),
754                enter_date: chrono::Utc::now(),
755            };
756            {
757                let mut conn = user.get_connection().await?;
758                let mut ticket1 = tx1.enter(&mut *conn).await?;
759                let split1a = Split {
760                    id: sqlx::types::Uuid::new_v4(),
761                    account_id: acc1,
762                    tx_id: tx1.id,
763                    value_num: 100,
764                    value_denom: 1,
765                    commodity_id,
766                    reconcile_state: None,
767                    reconcile_date: None,
768                    lot_id: None,
769                };
770                let split1b = Split {
771                    id: sqlx::types::Uuid::new_v4(),
772                    account_id: acc2,
773                    tx_id: tx1.id,
774                    value_num: -100,
775                    value_denom: 1,
776                    commodity_id,
777                    reconcile_state: None,
778                    reconcile_date: None,
779                    lot_id: None,
780                };
781                ticket1.add_splits(&[&split1a, &split1b]).await?;
782                ticket1.commit().await?;
783            }
784            {
785                let mut conn = user.get_connection().await?;
786                let mut ticket2 = tx2.enter(&mut *conn).await?;
787                let split2a = Split {
788                    id: sqlx::types::Uuid::new_v4(),
789                    account_id: acc1,
790                    tx_id: tx2.id,
791                    value_num: 200,
792                    value_denom: 1,
793                    commodity_id,
794                    reconcile_state: None,
795                    reconcile_date: None,
796                    lot_id: None,
797                };
798                let split2b = Split {
799                    id: sqlx::types::Uuid::new_v4(),
800                    account_id: acc2,
801                    tx_id: tx2.id,
802                    value_num: -200,
803                    value_denom: 1,
804                    commodity_id,
805                    reconcile_state: None,
806                    reconcile_date: None,
807                    lot_id: None,
808                };
809                ticket2.add_splits(&[&split2a, &split2b]).await?;
810                ticket2.commit().await?;
811            }
812            user.create_transaction_tag(tx1.id, "category".to_string(), "food".to_string(), None)
813                .await?;
814            user.create_transaction_tag(
815                tx2.id,
816                "category".to_string(),
817                "transport".to_string(),
818                None,
819            )
820            .await?;
821            user.create_transaction_tag(tx1.id, "priority".to_string(), "high".to_string(), None)
822                .await?;
823
824            let category_values = user.list_transaction_tag_values("category").await?;
825
826            assert_eq!(category_values.len(), 2);
827            assert!(category_values.contains(&"food".to_string()));
828            assert!(category_values.contains(&"transport".to_string()));
829
830            let priority_values = user.list_transaction_tag_values("priority").await?;
831
832            assert_eq!(priority_values.len(), 1);
833            assert!(priority_values.contains(&"high".to_string()));
834        }
835
836        #[local_db_sqlx_test]
837        async fn test_list_transaction_tag_values_nonexistent_name(
838            pool: PgPool,
839        ) -> Result<(), anyhow::Error> {
840            let user = USER.get().unwrap();
841            user.commit()
842                .await
843                .expect("Failed to commit user to database");
844
845            let commodity_id = user
846                .create_commodity("USD".to_string(), "US Dollar".to_string())
847                .await?
848                .id;
849            let acc1 = user.create_account("test_acc1", None).await?.id;
850            let acc2 = user.create_account("test_acc2", None).await?.id;
851
852            let tx = Transaction {
853                id: sqlx::types::Uuid::new_v4(),
854                post_date: chrono::Utc::now(),
855                enter_date: chrono::Utc::now(),
856            };
857
858            let mut conn = user.get_connection().await?;
859            let mut ticket = tx.enter(&mut *conn).await?;
860            let split1 = Split {
861                id: sqlx::types::Uuid::new_v4(),
862                account_id: acc1,
863                tx_id: tx.id,
864                value_num: 100,
865                value_denom: 1,
866                commodity_id,
867                reconcile_state: None,
868                reconcile_date: None,
869                lot_id: None,
870            };
871            let split2 = Split {
872                id: sqlx::types::Uuid::new_v4(),
873                account_id: acc2,
874                tx_id: tx.id,
875                value_num: -100,
876                value_denom: 1,
877                commodity_id,
878                reconcile_state: None,
879                reconcile_date: None,
880                lot_id: None,
881            };
882            ticket.add_splits(&[&split1, &split2]).await?;
883            ticket.commit().await?;
884
885            user.create_transaction_tag(tx.id, "category".to_string(), "food".to_string(), None)
886                .await?;
887
888            let values = user.list_transaction_tag_values("nonexistent").await?;
889
890            assert!(values.is_empty());
891        }
892
893        #[local_db_sqlx_test]
894        async fn test_create_split_tag(pool: PgPool) -> Result<(), anyhow::Error> {
895            let user = USER.get().unwrap();
896            user.commit()
897                .await
898                .expect("Failed to commit user to database");
899
900            let commodity_id = user
901                .create_commodity("USD".to_string(), "US Dollar".to_string())
902                .await?
903                .id;
904            let acc1 = user.create_account("test_acc1", None).await?.id;
905            let acc2 = user.create_account("test_acc2", None).await?.id;
906
907            let tx = Transaction {
908                id: sqlx::types::Uuid::new_v4(),
909                post_date: chrono::Utc::now(),
910                enter_date: chrono::Utc::now(),
911            };
912
913            let mut conn = user.get_connection().await?;
914            let mut ticket = tx.enter(&mut *conn).await?;
915            let split1 = Split {
916                id: sqlx::types::Uuid::new_v4(),
917                account_id: acc1,
918                tx_id: tx.id,
919                value_num: 100,
920                value_denom: 1,
921                commodity_id,
922                reconcile_state: None,
923                reconcile_date: None,
924                lot_id: None,
925            };
926            let split2 = Split {
927                id: sqlx::types::Uuid::new_v4(),
928                account_id: acc2,
929                tx_id: tx.id,
930                value_num: -100,
931                value_denom: 1,
932                commodity_id,
933                reconcile_state: None,
934                reconcile_date: None,
935                lot_id: None,
936            };
937            ticket.add_splits(&[&split1, &split2]).await?;
938            ticket.commit().await?;
939
940            let tag_id = user
941                .create_split_tag(
942                    split1.id,
943                    "project".to_string(),
944                    "nomisync".to_string(),
945                    Some("Split tag for project tracking".to_string()),
946                )
947                .await?;
948
949            let mut conn = user.get_connection().await?;
950            let res = sqlx::query!(
951                "SELECT tag_id FROM split_tags WHERE split_id = $1",
952                &split1.id
953            )
954            .fetch_one(&mut *conn)
955            .await?;
956
957            assert_eq!(res.tag_id, tag_id);
958
959            let tag = user.get_tag(tag_id).await?;
960            assert_eq!(tag.tag_name, "project");
961            assert_eq!(tag.tag_value, "nomisync");
962            assert_eq!(
963                tag.description,
964                Some("Split tag for project tracking".to_string())
965            );
966        }
967
968        #[local_db_sqlx_test]
969        async fn test_list_split_tag_names_empty(pool: PgPool) -> Result<(), anyhow::Error> {
970            let user = USER.get().unwrap();
971            user.commit()
972                .await
973                .expect("Failed to commit user to database");
974
975            let names = user.list_split_tag_names().await?;
976
977            assert!(names.is_empty());
978        }
979
980        #[local_db_sqlx_test]
981        async fn test_list_split_tag_names_with_data(pool: PgPool) -> Result<(), anyhow::Error> {
982            let user = USER.get().unwrap();
983            user.commit()
984                .await
985                .expect("Failed to commit user to database");
986
987            let commodity_id = user
988                .create_commodity("USD".to_string(), "US Dollar".to_string())
989                .await?
990                .id;
991            let acc1 = user.create_account("test_acc1", None).await?.id;
992            let acc2 = user.create_account("test_acc2", None).await?.id;
993
994            let tx = Transaction {
995                id: sqlx::types::Uuid::new_v4(),
996                post_date: chrono::Utc::now(),
997                enter_date: chrono::Utc::now(),
998            };
999
1000            let mut conn = user.get_connection().await?;
1001            let mut ticket = tx.enter(&mut *conn).await?;
1002            let split1 = Split {
1003                id: sqlx::types::Uuid::new_v4(),
1004                account_id: acc1,
1005                tx_id: tx.id,
1006                value_num: 100,
1007                value_denom: 1,
1008                commodity_id,
1009                reconcile_state: None,
1010                reconcile_date: None,
1011                lot_id: None,
1012            };
1013            let split2 = Split {
1014                id: sqlx::types::Uuid::new_v4(),
1015                account_id: acc2,
1016                tx_id: tx.id,
1017                value_num: -100,
1018                value_denom: 1,
1019                commodity_id,
1020                reconcile_state: None,
1021                reconcile_date: None,
1022                lot_id: None,
1023            };
1024            ticket.add_splits(&[&split1, &split2]).await?;
1025            ticket.commit().await?;
1026
1027            user.create_split_tag(
1028                split1.id,
1029                "project".to_string(),
1030                "nomisync".to_string(),
1031                None,
1032            )
1033            .await?;
1034            user.create_split_tag(
1035                split2.id,
1036                "department".to_string(),
1037                "engineering".to_string(),
1038                None,
1039            )
1040            .await?;
1041
1042            let names = user.list_split_tag_names().await?;
1043
1044            assert_eq!(names.len(), 2);
1045            assert!(names.contains(&"project".to_string()));
1046            assert!(names.contains(&"department".to_string()));
1047        }
1048
1049        #[local_db_sqlx_test]
1050        async fn test_list_split_tag_values_empty(pool: PgPool) -> Result<(), anyhow::Error> {
1051            let user = USER.get().unwrap();
1052            user.commit()
1053                .await
1054                .expect("Failed to commit user to database");
1055
1056            let values = user.list_split_tag_values("project").await?;
1057
1058            assert!(values.is_empty());
1059        }
1060
1061        #[local_db_sqlx_test]
1062        async fn test_list_split_tag_values_with_data(pool: PgPool) -> Result<(), anyhow::Error> {
1063            let user = USER.get().unwrap();
1064            user.commit()
1065                .await
1066                .expect("Failed to commit user to database");
1067
1068            let commodity_id = user
1069                .create_commodity("USD".to_string(), "US Dollar".to_string())
1070                .await?
1071                .id;
1072            let acc1 = user.create_account("test_acc1", None).await?.id;
1073            let acc2 = user.create_account("test_acc2", None).await?.id;
1074
1075            let tx1 = Transaction {
1076                id: sqlx::types::Uuid::new_v4(),
1077                post_date: chrono::Utc::now(),
1078                enter_date: chrono::Utc::now(),
1079            };
1080            let tx2 = Transaction {
1081                id: sqlx::types::Uuid::new_v4(),
1082                post_date: chrono::Utc::now(),
1083                enter_date: chrono::Utc::now(),
1084            };
1085
1086            let split1_id = sqlx::types::Uuid::new_v4();
1087            let split2_id = sqlx::types::Uuid::new_v4();
1088            let split3_id = sqlx::types::Uuid::new_v4();
1089            let split4_id = sqlx::types::Uuid::new_v4();
1090
1091            {
1092                let mut conn = user.get_connection().await?;
1093                let mut ticket1 = tx1.enter(&mut *conn).await?;
1094                let split1a = Split {
1095                    id: split1_id,
1096                    account_id: acc1,
1097                    tx_id: tx1.id,
1098                    value_num: 100,
1099                    value_denom: 1,
1100                    commodity_id,
1101                    reconcile_state: None,
1102                    reconcile_date: None,
1103                    lot_id: None,
1104                };
1105                let split1b = Split {
1106                    id: split2_id,
1107                    account_id: acc2,
1108                    tx_id: tx1.id,
1109                    value_num: -100,
1110                    value_denom: 1,
1111                    commodity_id,
1112                    reconcile_state: None,
1113                    reconcile_date: None,
1114                    lot_id: None,
1115                };
1116                ticket1.add_splits(&[&split1a, &split1b]).await?;
1117                ticket1.commit().await?;
1118            }
1119            {
1120                let mut conn = user.get_connection().await?;
1121                let mut ticket2 = tx2.enter(&mut *conn).await?;
1122                let split2a = Split {
1123                    id: split3_id,
1124                    account_id: acc1,
1125                    tx_id: tx2.id,
1126                    value_num: 200,
1127                    value_denom: 1,
1128                    commodity_id,
1129                    reconcile_state: None,
1130                    reconcile_date: None,
1131                    lot_id: None,
1132                };
1133                let split2b = Split {
1134                    id: split4_id,
1135                    account_id: acc2,
1136                    tx_id: tx2.id,
1137                    value_num: -200,
1138                    value_denom: 1,
1139                    commodity_id,
1140                    reconcile_state: None,
1141                    reconcile_date: None,
1142                    lot_id: None,
1143                };
1144                ticket2.add_splits(&[&split2a, &split2b]).await?;
1145                ticket2.commit().await?;
1146            }
1147
1148            user.create_split_tag(
1149                split1_id,
1150                "project".to_string(),
1151                "nomisync".to_string(),
1152                None,
1153            )
1154            .await?;
1155            user.create_split_tag(
1156                split3_id,
1157                "project".to_string(),
1158                "website".to_string(),
1159                None,
1160            )
1161            .await?;
1162            user.create_split_tag(
1163                split2_id,
1164                "department".to_string(),
1165                "engineering".to_string(),
1166                None,
1167            )
1168            .await?;
1169
1170            let project_values = user.list_split_tag_values("project").await?;
1171
1172            assert_eq!(project_values.len(), 2);
1173            assert!(project_values.contains(&"nomisync".to_string()));
1174            assert!(project_values.contains(&"website".to_string()));
1175
1176            let department_values = user.list_split_tag_values("department").await?;
1177
1178            assert_eq!(department_values.len(), 1);
1179            assert!(department_values.contains(&"engineering".to_string()));
1180        }
1181
1182        #[local_db_sqlx_test]
1183        async fn test_list_split_tag_values_nonexistent_name(
1184            pool: PgPool,
1185        ) -> Result<(), anyhow::Error> {
1186            let user = USER.get().unwrap();
1187            user.commit()
1188                .await
1189                .expect("Failed to commit user to database");
1190
1191            let commodity_id = user
1192                .create_commodity("USD".to_string(), "US Dollar".to_string())
1193                .await?
1194                .id;
1195            let acc1 = user.create_account("test_acc1", None).await?.id;
1196            let acc2 = user.create_account("test_acc2", None).await?.id;
1197
1198            let tx = Transaction {
1199                id: sqlx::types::Uuid::new_v4(),
1200                post_date: chrono::Utc::now(),
1201                enter_date: chrono::Utc::now(),
1202            };
1203
1204            let mut conn = user.get_connection().await?;
1205            let mut ticket = tx.enter(&mut *conn).await?;
1206            let split1 = Split {
1207                id: sqlx::types::Uuid::new_v4(),
1208                account_id: acc1,
1209                tx_id: tx.id,
1210                value_num: 100,
1211                value_denom: 1,
1212                commodity_id,
1213                reconcile_state: None,
1214                reconcile_date: None,
1215                lot_id: None,
1216            };
1217            let split2 = Split {
1218                id: sqlx::types::Uuid::new_v4(),
1219                account_id: acc2,
1220                tx_id: tx.id,
1221                value_num: -100,
1222                value_denom: 1,
1223                commodity_id,
1224                reconcile_state: None,
1225                reconcile_date: None,
1226                lot_id: None,
1227            };
1228            ticket.add_splits(&[&split1, &split2]).await?;
1229            ticket.commit().await?;
1230
1231            user.create_split_tag(
1232                split1.id,
1233                "project".to_string(),
1234                "nomisync".to_string(),
1235                None,
1236            )
1237            .await?;
1238
1239            let values = user.list_split_tag_values("nonexistent").await?;
1240
1241            assert!(values.is_empty());
1242        }
1243
1244        #[local_db_sqlx_test]
1245        async fn test_create_account_tag(pool: PgPool) -> Result<(), anyhow::Error> {
1246            let user = USER.get().unwrap();
1247            user.commit()
1248                .await
1249                .expect("Failed to commit user to database");
1250
1251            let acc = user.create_account("test_acc", None).await?;
1252
1253            let tag_id = user
1254                .create_account_tag(
1255                    acc.id,
1256                    "category".to_string(),
1257                    "assets".to_string(),
1258                    Some("Account category".to_string()),
1259                )
1260                .await?;
1261
1262            let mut conn = user.get_connection().await?;
1263            let res = sqlx::query!(
1264                "SELECT tag_id FROM account_tags WHERE account_id = $1 AND tag_id = $2",
1265                &acc.id,
1266                &tag_id
1267            )
1268            .fetch_one(&mut *conn)
1269            .await?;
1270
1271            assert_eq!(res.tag_id, tag_id);
1272
1273            let tag = user.get_tag(tag_id).await?;
1274            assert_eq!(tag.tag_name, "category");
1275            assert_eq!(tag.tag_value, "assets");
1276            assert_eq!(tag.description, Some("Account category".to_string()));
1277        }
1278
1279        #[local_db_sqlx_test]
1280        async fn test_list_account_tag_names_empty(pool: PgPool) -> Result<(), anyhow::Error> {
1281            let user = USER.get().unwrap();
1282            user.commit()
1283                .await
1284                .expect("Failed to commit user to database");
1285
1286            let names = user.list_account_tag_names().await?;
1287
1288            assert!(names.is_empty());
1289        }
1290
1291        #[local_db_sqlx_test]
1292        async fn test_list_account_tag_names_with_data(pool: PgPool) -> Result<(), anyhow::Error> {
1293            let user = USER.get().unwrap();
1294            user.commit()
1295                .await
1296                .expect("Failed to commit user to database");
1297
1298            let acc1 = user.create_account("test_acc1", None).await?;
1299            let acc2 = user.create_account("test_acc2", None).await?;
1300
1301            user.set_account_tag(
1302                &acc1,
1303                &Tag {
1304                    id: Uuid::new_v4(),
1305                    tag_name: "category".to_string(),
1306                    tag_value: "assets".to_string(),
1307                    description: None,
1308                },
1309            )
1310            .await?;
1311            user.set_account_tag(
1312                &acc2,
1313                &Tag {
1314                    id: Uuid::new_v4(),
1315                    tag_name: "priority".to_string(),
1316                    tag_value: "high".to_string(),
1317                    description: None,
1318                },
1319            )
1320            .await?;
1321
1322            let names = user.list_account_tag_names().await?;
1323
1324            assert!(names.contains(&"category".to_string()));
1325            assert!(names.contains(&"priority".to_string()));
1326        }
1327
1328        #[local_db_sqlx_test]
1329        async fn test_list_account_tag_values_empty(pool: PgPool) -> Result<(), anyhow::Error> {
1330            let user = USER.get().unwrap();
1331            user.commit()
1332                .await
1333                .expect("Failed to commit user to database");
1334
1335            let values = user.list_account_tag_values("category").await?;
1336
1337            assert!(values.is_empty());
1338        }
1339
1340        #[local_db_sqlx_test]
1341        async fn test_list_account_tag_values_with_data(pool: PgPool) -> Result<(), anyhow::Error> {
1342            let user = USER.get().unwrap();
1343            user.commit()
1344                .await
1345                .expect("Failed to commit user to database");
1346
1347            let acc1 = user.create_account("test_acc1", None).await?;
1348            let acc2 = user.create_account("test_acc2", None).await?;
1349
1350            user.set_account_tag(
1351                &acc1,
1352                &Tag {
1353                    id: Uuid::new_v4(),
1354                    tag_name: "category".to_string(),
1355                    tag_value: "assets".to_string(),
1356                    description: None,
1357                },
1358            )
1359            .await?;
1360            user.set_account_tag(
1361                &acc2,
1362                &Tag {
1363                    id: Uuid::new_v4(),
1364                    tag_name: "category".to_string(),
1365                    tag_value: "liabilities".to_string(),
1366                    description: None,
1367                },
1368            )
1369            .await?;
1370
1371            let values = user.list_account_tag_values("category").await?;
1372
1373            assert_eq!(values.len(), 2);
1374            assert!(values.contains(&"assets".to_string()));
1375            assert!(values.contains(&"liabilities".to_string()));
1376
1377            let nonexistent = user.list_account_tag_values("nonexistent").await?;
1378
1379            assert!(nonexistent.is_empty());
1380        }
1381    }
1382}