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 artifact_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        /// Upsert-style set: replace any existing (split, tag_name) link with
352        /// the (tag_name, tag_value) pair from `t`. Mirrors `set_account_tag`
353        /// — script-friendly idempotent set semantics.
354        pub async fn set_split_tag(&self, split_id: Uuid, t: &Tag) -> Result<(), ServerError> {
355            if t.tag_name.trim().is_empty() || t.tag_value.trim().is_empty() {
356                return Err(ServerError::Creation);
357            }
358            let mut conn = self.get_connection().await.map_err(|err| {
359                log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
360                ServerError::DB(err)
361            })?;
362
363            sqlx::query_file!(
364                "sql/set/splits/tag.sql",
365                &split_id,
366                &t.tag_name,
367                &t.tag_value,
368                t.description
369            )
370            .execute(&mut *conn)
371            .await
372            .map_err(|err| {
373                log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
374                ServerError::DB(crate::db::DBError::Sqlx(err))
375            })?;
376
377            Ok(())
378        }
379
380        /// Companion to `set_split_tag` for transactions.
381        pub async fn set_transaction_tag(&self, tx_id: Uuid, t: &Tag) -> Result<(), ServerError> {
382            if t.tag_name.trim().is_empty() || t.tag_value.trim().is_empty() {
383                return Err(ServerError::Creation);
384            }
385            let mut conn = self.get_connection().await.map_err(|err| {
386                log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
387                ServerError::DB(err)
388            })?;
389
390            sqlx::query_file!(
391                "sql/set/transactions/tag.sql",
392                &tx_id,
393                &t.tag_name,
394                &t.tag_value,
395                t.description
396            )
397            .execute(&mut *conn)
398            .await
399            .map_err(|err| {
400                log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
401                ServerError::DB(crate::db::DBError::Sqlx(err))
402            })?;
403
404            Ok(())
405        }
406
407        /// Read all tags attached to a single split. Parallel to
408        /// `get_transaction_tags`; the script-side `get-split-tag` native
409        /// looks up by name in the returned vec.
410        pub async fn get_split_tags(&self, split_id: Uuid) -> Result<Vec<Tag>, ServerError> {
411            let mut conn = self.get_connection().await?;
412
413            let tags = sqlx::query_file_as!(Tag, "sql/select/tags/by_split.sql", &split_id)
414                .fetch_all(&mut *conn)
415                .await
416                .map_err(|err| {
417                    log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
418                    ServerError::DB(crate::db::DBError::Sqlx(err))
419                })?;
420
421            Ok(tags)
422        }
423
424        pub async fn create_account_tag(
425            &self,
426            account_id: Uuid,
427            name: String,
428            value: String,
429            description: Option<String>,
430        ) -> Result<Uuid, ServerError> {
431            let tag_id = self.create_tag(name, value, description).await?;
432            let mut conn = self.get_connection().await?;
433            sqlx::query_file!(
434                "sql/insert/account_tags/account_tag.sql",
435                &account_id,
436                &tag_id
437            )
438            .execute(&mut *conn)
439            .await
440            .map_err(|err| {
441                log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
442                ServerError::DB(crate::db::DBError::Sqlx(err))
443            })?;
444            Ok(tag_id)
445        }
446
447        pub async fn list_account_tag_names(&self) -> Result<Vec<String>, ServerError> {
448            self.list_tag_names(TagScope::Account).await
449        }
450
451        pub async fn list_account_tag_values(
452            &self,
453            tag_name: &str,
454        ) -> Result<Vec<String>, ServerError> {
455            self.list_tag_values_for(TagScope::Account, tag_name).await
456        }
457
458        pub async fn list_split_tag_names(&self) -> Result<Vec<String>, ServerError> {
459            self.list_tag_names(TagScope::Split).await
460        }
461
462        pub async fn list_split_tag_values(
463            &self,
464            tag_name: &str,
465        ) -> Result<Vec<String>, ServerError> {
466            self.list_tag_values_for(TagScope::Split, tag_name).await
467        }
468    }
469
470    #[cfg(test)]
471    mod tag_tests {
472        use super::*;
473        use crate::db::DB_POOL;
474        #[cfg(feature = "testlog")]
475        use env_logger;
476        use finance::{split::Split, transaction::Transaction};
477        #[cfg(feature = "testlog")]
478        use log;
479        use sqlx::PgPool;
480        use sqlx::types::chrono;
481
482        use supp_macro::local_db_sqlx_test;
483        use tokio::sync::OnceCell;
484
485        /// Context for keeping environment intact
486        static CONTEXT: OnceCell<()> = OnceCell::const_new();
487        static USER: OnceCell<User> = OnceCell::const_new();
488
489        async fn setup() {
490            CONTEXT
491                .get_or_init(|| async {
492                    #[cfg(feature = "testlog")]
493                    let _ = env_logger::builder()
494                        .is_test(true)
495                        .filter_level(log::LevelFilter::Trace)
496                        .try_init();
497                })
498                .await;
499            USER.get_or_init(|| async { User { id: Uuid::new_v4() } })
500                .await;
501        }
502
503        #[local_db_sqlx_test]
504        async fn test_tag_creation(pool: PgPool) -> Result<(), anyhow::Error> {
505            let user = USER.get().unwrap();
506            user.commit()
507                .await
508                .expect("Failed to commit user to database");
509
510            let id = user
511                .create_tag("testtag".to_string(), "testval".to_string(), None)
512                .await?;
513
514            let mut conn = user.get_connection().await?;
515            let res = sqlx::query_file!("testdata/query_tag_by_id.sql", &id)
516                .fetch_one(&mut *conn)
517                .await?;
518
519            assert_eq!(res.tag_name, "testtag".to_string());
520            assert_eq!(res.tag_value, "testval".to_string());
521            assert_eq!(res.description, None);
522        }
523
524        #[local_db_sqlx_test]
525        async fn test_tag_creation_with_description(pool: PgPool) -> Result<(), anyhow::Error> {
526            let user = USER.get().unwrap();
527            user.commit()
528                .await
529                .expect("Failed to commit user to database");
530
531            let id = user
532                .create_tag(
533                    "categorytag".to_string(),
534                    "category1".to_string(),
535                    Some("Test description".to_string()),
536                )
537                .await?;
538
539            let tag = user.get_tag(id).await?;
540
541            assert_eq!(tag.tag_name, "categorytag");
542            assert_eq!(tag.tag_value, "category1");
543            assert_eq!(tag.description, Some("Test description".to_string()));
544        }
545
546        #[local_db_sqlx_test]
547        async fn test_list_tags(pool: PgPool) -> Result<(), anyhow::Error> {
548            let user = USER.get().unwrap();
549            user.commit()
550                .await
551                .expect("Failed to commit user to database");
552
553            let id1 = user
554                .create_tag("tag1".to_string(), "value1".to_string(), None)
555                .await?;
556
557            let id2 = user
558                .create_tag(
559                    "tag2".to_string(),
560                    "value2".to_string(),
561                    Some("desc".to_string()),
562                )
563                .await?;
564
565            let tags = user.list_tags().await?;
566
567            assert!(tags.len() >= 2);
568            assert!(tags.iter().any(|t| t.id == id1));
569            assert!(tags.iter().any(|t| t.id == id2));
570
571            let tag1 = tags.iter().find(|t| t.id == id1).unwrap();
572            assert_eq!(tag1.tag_name, "tag1");
573            assert_eq!(tag1.tag_value, "value1");
574            assert_eq!(tag1.description, None);
575
576            let tag2 = tags.iter().find(|t| t.id == id2).unwrap();
577            assert_eq!(tag2.tag_name, "tag2");
578            assert_eq!(tag2.tag_value, "value2");
579            assert_eq!(tag2.description, Some("desc".to_string()));
580        }
581
582        #[local_db_sqlx_test]
583        async fn test_get_tag(pool: PgPool) -> Result<(), anyhow::Error> {
584            let user = USER.get().unwrap();
585            user.commit()
586                .await
587                .expect("Failed to commit user to database");
588
589            let id = user
590                .create_tag(
591                    "gettag".to_string(),
592                    "getvalue".to_string(),
593                    Some("Get description".to_string()),
594                )
595                .await?;
596
597            let tag = user.get_tag(id).await?;
598
599            assert_eq!(tag.id, id);
600            assert_eq!(tag.tag_name, "gettag");
601            assert_eq!(tag.tag_value, "getvalue");
602            assert_eq!(tag.description, Some("Get description".to_string()));
603        }
604
605        #[local_db_sqlx_test]
606        async fn test_get_nonexistent_tag(pool: PgPool) -> Result<(), anyhow::Error> {
607            let user = USER.get().unwrap();
608            user.commit()
609                .await
610                .expect("Failed to commit user to database");
611
612            let nonexistent_id = Uuid::new_v4();
613            let result = user.get_tag(nonexistent_id).await;
614
615            assert!(result.is_err());
616        }
617
618        #[local_db_sqlx_test]
619        async fn test_update_tag(pool: PgPool) -> Result<(), anyhow::Error> {
620            let user = USER.get().unwrap();
621            user.commit()
622                .await
623                .expect("Failed to commit user to database");
624
625            let id = user
626                .create_tag("oldname".to_string(), "oldvalue".to_string(), None)
627                .await?;
628
629            user.update_tag(
630                id,
631                "newname".to_string(),
632                "newvalue".to_string(),
633                Some("Updated description".to_string()),
634            )
635            .await?;
636
637            let tag = user.get_tag(id).await?;
638
639            assert_eq!(tag.id, id);
640            assert_eq!(tag.tag_name, "newname");
641            assert_eq!(tag.tag_value, "newvalue");
642            assert_eq!(tag.description, Some("Updated description".to_string()));
643        }
644
645        #[local_db_sqlx_test]
646        async fn test_update_tag_remove_description(pool: PgPool) {
647            let user = USER.get().unwrap();
648            user.commit()
649                .await
650                .expect("Failed to commit user to database");
651
652            let id = user
653                .create_tag(
654                    "tagname".to_string(),
655                    "tagvalue".to_string(),
656                    Some("Initial description".to_string()),
657                )
658                .await?;
659
660            user.update_tag(id, "tagname".to_string(), "tagvalue".to_string(), None)
661                .await?;
662
663            let tag = user.get_tag(id).await?;
664
665            assert_eq!(tag.description, None);
666        }
667
668        #[local_db_sqlx_test]
669        async fn test_delete_tag(pool: PgPool) -> Result<(), anyhow::Error> {
670            let user = USER.get().unwrap();
671            user.commit()
672                .await
673                .expect("Failed to commit user to database");
674
675            let id = user
676                .create_tag("deletetag".to_string(), "deletevalue".to_string(), None)
677                .await?;
678
679            let tag = user.get_tag(id).await;
680            assert!(tag.is_ok());
681
682            user.delete_tag(id).await?;
683
684            let result = user.get_tag(id).await;
685            assert!(result.is_err());
686        }
687
688        #[local_db_sqlx_test]
689        async fn test_delete_nonexistent_tag(pool: PgPool) -> Result<(), anyhow::Error> {
690            let user = USER.get().unwrap();
691            user.commit()
692                .await
693                .expect("Failed to commit user to database");
694
695            let nonexistent_id = Uuid::new_v4();
696            let result = user.delete_tag(nonexistent_id).await;
697
698            assert!(result.is_ok());
699        }
700
701        #[local_db_sqlx_test]
702        async fn test_list_tags_empty(pool: PgPool) -> Result<(), anyhow::Error> {
703            let user = USER.get().unwrap();
704            user.commit()
705                .await
706                .expect("Failed to commit user to database");
707
708            let tags = user.list_tags().await?;
709
710            assert!(
711                tags.is_empty()
712                    || tags.iter().all(|t| t.tag_name == "name"
713                        || t.tag_name == "note"
714                        || t.tag_name == "symbol")
715            );
716        }
717
718        #[local_db_sqlx_test]
719        async fn test_list_transaction_tag_names_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 names = user.list_transaction_tag_names().await?;
726
727            assert!(names.is_empty());
728        }
729
730        #[local_db_sqlx_test]
731        async fn test_list_transaction_tag_names_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 tx = Transaction {
747                id: sqlx::types::Uuid::new_v4(),
748                post_date: chrono::Utc::now(),
749                enter_date: chrono::Utc::now(),
750            };
751
752            let mut conn = user.get_connection().await?;
753            let mut ticket = tx.enter(&mut *conn).await?;
754            let split1 = Split {
755                id: sqlx::types::Uuid::new_v4(),
756                account_id: acc1,
757                tx_id: tx.id,
758                value_num: 100,
759                value_denom: 1,
760                commodity_id,
761                reconcile_state: None,
762                reconcile_date: None,
763                lot_id: None,
764            };
765            let split2 = Split {
766                id: sqlx::types::Uuid::new_v4(),
767                account_id: acc2,
768                tx_id: tx.id,
769                value_num: -100,
770                value_denom: 1,
771                commodity_id,
772                reconcile_state: None,
773                reconcile_date: None,
774                lot_id: None,
775            };
776            ticket.add_splits(&[&split1, &split2]).await?;
777            ticket.commit().await?;
778
779            user.create_transaction_tag(tx.id, "category".to_string(), "food".to_string(), None)
780                .await?;
781            user.create_transaction_tag(tx.id, "priority".to_string(), "high".to_string(), None)
782                .await?;
783
784            let names = user.list_transaction_tag_names().await?;
785
786            assert_eq!(names.len(), 2);
787            assert!(names.contains(&"category".to_string()));
788            assert!(names.contains(&"priority".to_string()));
789        }
790
791        #[local_db_sqlx_test]
792        async fn test_list_transaction_tag_values_empty(pool: PgPool) -> Result<(), anyhow::Error> {
793            let user = USER.get().unwrap();
794            user.commit()
795                .await
796                .expect("Failed to commit user to database");
797
798            let values = user.list_transaction_tag_values("category").await?;
799
800            assert!(values.is_empty());
801        }
802
803        #[local_db_sqlx_test]
804        async fn test_list_transaction_tag_values_with_data(
805            pool: PgPool,
806        ) -> Result<(), anyhow::Error> {
807            let user = USER.get().unwrap();
808            user.commit()
809                .await
810                .expect("Failed to commit user to database");
811
812            let commodity_id = user
813                .create_commodity("USD".to_string(), "US Dollar".to_string())
814                .await?
815                .id;
816            let acc1 = user.create_account("test_acc1", None).await?.id;
817            let acc2 = user.create_account("test_acc2", None).await?.id;
818
819            let tx1 = Transaction {
820                id: sqlx::types::Uuid::new_v4(),
821                post_date: chrono::Utc::now(),
822                enter_date: chrono::Utc::now(),
823            };
824            let tx2 = Transaction {
825                id: sqlx::types::Uuid::new_v4(),
826                post_date: chrono::Utc::now(),
827                enter_date: chrono::Utc::now(),
828            };
829            {
830                let mut conn = user.get_connection().await?;
831                let mut ticket1 = tx1.enter(&mut *conn).await?;
832                let split1a = Split {
833                    id: sqlx::types::Uuid::new_v4(),
834                    account_id: acc1,
835                    tx_id: tx1.id,
836                    value_num: 100,
837                    value_denom: 1,
838                    commodity_id,
839                    reconcile_state: None,
840                    reconcile_date: None,
841                    lot_id: None,
842                };
843                let split1b = Split {
844                    id: sqlx::types::Uuid::new_v4(),
845                    account_id: acc2,
846                    tx_id: tx1.id,
847                    value_num: -100,
848                    value_denom: 1,
849                    commodity_id,
850                    reconcile_state: None,
851                    reconcile_date: None,
852                    lot_id: None,
853                };
854                ticket1.add_splits(&[&split1a, &split1b]).await?;
855                ticket1.commit().await?;
856            }
857            {
858                let mut conn = user.get_connection().await?;
859                let mut ticket2 = tx2.enter(&mut *conn).await?;
860                let split2a = Split {
861                    id: sqlx::types::Uuid::new_v4(),
862                    account_id: acc1,
863                    tx_id: tx2.id,
864                    value_num: 200,
865                    value_denom: 1,
866                    commodity_id,
867                    reconcile_state: None,
868                    reconcile_date: None,
869                    lot_id: None,
870                };
871                let split2b = Split {
872                    id: sqlx::types::Uuid::new_v4(),
873                    account_id: acc2,
874                    tx_id: tx2.id,
875                    value_num: -200,
876                    value_denom: 1,
877                    commodity_id,
878                    reconcile_state: None,
879                    reconcile_date: None,
880                    lot_id: None,
881                };
882                ticket2.add_splits(&[&split2a, &split2b]).await?;
883                ticket2.commit().await?;
884            }
885            user.create_transaction_tag(tx1.id, "category".to_string(), "food".to_string(), None)
886                .await?;
887            user.create_transaction_tag(
888                tx2.id,
889                "category".to_string(),
890                "transport".to_string(),
891                None,
892            )
893            .await?;
894            user.create_transaction_tag(tx1.id, "priority".to_string(), "high".to_string(), None)
895                .await?;
896
897            let category_values = user.list_transaction_tag_values("category").await?;
898
899            assert_eq!(category_values.len(), 2);
900            assert!(category_values.contains(&"food".to_string()));
901            assert!(category_values.contains(&"transport".to_string()));
902
903            let priority_values = user.list_transaction_tag_values("priority").await?;
904
905            assert_eq!(priority_values.len(), 1);
906            assert!(priority_values.contains(&"high".to_string()));
907        }
908
909        #[local_db_sqlx_test]
910        async fn test_list_transaction_tag_values_nonexistent_name(
911            pool: PgPool,
912        ) -> Result<(), anyhow::Error> {
913            let user = USER.get().unwrap();
914            user.commit()
915                .await
916                .expect("Failed to commit user to database");
917
918            let commodity_id = user
919                .create_commodity("USD".to_string(), "US Dollar".to_string())
920                .await?
921                .id;
922            let acc1 = user.create_account("test_acc1", None).await?.id;
923            let acc2 = user.create_account("test_acc2", None).await?.id;
924
925            let tx = Transaction {
926                id: sqlx::types::Uuid::new_v4(),
927                post_date: chrono::Utc::now(),
928                enter_date: chrono::Utc::now(),
929            };
930
931            let mut conn = user.get_connection().await?;
932            let mut ticket = tx.enter(&mut *conn).await?;
933            let split1 = Split {
934                id: sqlx::types::Uuid::new_v4(),
935                account_id: acc1,
936                tx_id: tx.id,
937                value_num: 100,
938                value_denom: 1,
939                commodity_id,
940                reconcile_state: None,
941                reconcile_date: None,
942                lot_id: None,
943            };
944            let split2 = Split {
945                id: sqlx::types::Uuid::new_v4(),
946                account_id: acc2,
947                tx_id: tx.id,
948                value_num: -100,
949                value_denom: 1,
950                commodity_id,
951                reconcile_state: None,
952                reconcile_date: None,
953                lot_id: None,
954            };
955            ticket.add_splits(&[&split1, &split2]).await?;
956            ticket.commit().await?;
957
958            user.create_transaction_tag(tx.id, "category".to_string(), "food".to_string(), None)
959                .await?;
960
961            let values = user.list_transaction_tag_values("nonexistent").await?;
962
963            assert!(values.is_empty());
964        }
965
966        #[local_db_sqlx_test]
967        async fn test_create_split_tag(pool: PgPool) -> Result<(), anyhow::Error> {
968            let user = USER.get().unwrap();
969            user.commit()
970                .await
971                .expect("Failed to commit user to database");
972
973            let commodity_id = user
974                .create_commodity("USD".to_string(), "US Dollar".to_string())
975                .await?
976                .id;
977            let acc1 = user.create_account("test_acc1", None).await?.id;
978            let acc2 = user.create_account("test_acc2", None).await?.id;
979
980            let tx = Transaction {
981                id: sqlx::types::Uuid::new_v4(),
982                post_date: chrono::Utc::now(),
983                enter_date: chrono::Utc::now(),
984            };
985
986            let mut conn = user.get_connection().await?;
987            let mut ticket = tx.enter(&mut *conn).await?;
988            let split1 = Split {
989                id: sqlx::types::Uuid::new_v4(),
990                account_id: acc1,
991                tx_id: tx.id,
992                value_num: 100,
993                value_denom: 1,
994                commodity_id,
995                reconcile_state: None,
996                reconcile_date: None,
997                lot_id: None,
998            };
999            let split2 = Split {
1000                id: sqlx::types::Uuid::new_v4(),
1001                account_id: acc2,
1002                tx_id: tx.id,
1003                value_num: -100,
1004                value_denom: 1,
1005                commodity_id,
1006                reconcile_state: None,
1007                reconcile_date: None,
1008                lot_id: None,
1009            };
1010            ticket.add_splits(&[&split1, &split2]).await?;
1011            ticket.commit().await?;
1012
1013            let tag_id = user
1014                .create_split_tag(
1015                    split1.id,
1016                    "project".to_string(),
1017                    "nomisync".to_string(),
1018                    Some("Split tag for project tracking".to_string()),
1019                )
1020                .await?;
1021
1022            let mut conn = user.get_connection().await?;
1023            let res = sqlx::query!(
1024                "SELECT tag_id FROM split_tags WHERE split_id = $1",
1025                &split1.id
1026            )
1027            .fetch_one(&mut *conn)
1028            .await?;
1029
1030            assert_eq!(res.tag_id, tag_id);
1031
1032            let tag = user.get_tag(tag_id).await?;
1033            assert_eq!(tag.tag_name, "project");
1034            assert_eq!(tag.tag_value, "nomisync");
1035            assert_eq!(
1036                tag.description,
1037                Some("Split tag for project tracking".to_string())
1038            );
1039        }
1040
1041        #[local_db_sqlx_test]
1042        async fn test_list_split_tag_names_empty(pool: PgPool) -> Result<(), anyhow::Error> {
1043            let user = USER.get().unwrap();
1044            user.commit()
1045                .await
1046                .expect("Failed to commit user to database");
1047
1048            let names = user.list_split_tag_names().await?;
1049
1050            assert!(names.is_empty());
1051        }
1052
1053        #[local_db_sqlx_test]
1054        async fn test_list_split_tag_names_with_data(pool: PgPool) -> Result<(), anyhow::Error> {
1055            let user = USER.get().unwrap();
1056            user.commit()
1057                .await
1058                .expect("Failed to commit user to database");
1059
1060            let commodity_id = user
1061                .create_commodity("USD".to_string(), "US Dollar".to_string())
1062                .await?
1063                .id;
1064            let acc1 = user.create_account("test_acc1", None).await?.id;
1065            let acc2 = user.create_account("test_acc2", None).await?.id;
1066
1067            let tx = Transaction {
1068                id: sqlx::types::Uuid::new_v4(),
1069                post_date: chrono::Utc::now(),
1070                enter_date: chrono::Utc::now(),
1071            };
1072
1073            let mut conn = user.get_connection().await?;
1074            let mut ticket = tx.enter(&mut *conn).await?;
1075            let split1 = Split {
1076                id: sqlx::types::Uuid::new_v4(),
1077                account_id: acc1,
1078                tx_id: tx.id,
1079                value_num: 100,
1080                value_denom: 1,
1081                commodity_id,
1082                reconcile_state: None,
1083                reconcile_date: None,
1084                lot_id: None,
1085            };
1086            let split2 = Split {
1087                id: sqlx::types::Uuid::new_v4(),
1088                account_id: acc2,
1089                tx_id: tx.id,
1090                value_num: -100,
1091                value_denom: 1,
1092                commodity_id,
1093                reconcile_state: None,
1094                reconcile_date: None,
1095                lot_id: None,
1096            };
1097            ticket.add_splits(&[&split1, &split2]).await?;
1098            ticket.commit().await?;
1099
1100            user.create_split_tag(
1101                split1.id,
1102                "project".to_string(),
1103                "nomisync".to_string(),
1104                None,
1105            )
1106            .await?;
1107            user.create_split_tag(
1108                split2.id,
1109                "department".to_string(),
1110                "engineering".to_string(),
1111                None,
1112            )
1113            .await?;
1114
1115            let names = user.list_split_tag_names().await?;
1116
1117            assert_eq!(names.len(), 2);
1118            assert!(names.contains(&"project".to_string()));
1119            assert!(names.contains(&"department".to_string()));
1120        }
1121
1122        #[local_db_sqlx_test]
1123        async fn test_list_split_tag_values_empty(pool: PgPool) -> Result<(), anyhow::Error> {
1124            let user = USER.get().unwrap();
1125            user.commit()
1126                .await
1127                .expect("Failed to commit user to database");
1128
1129            let values = user.list_split_tag_values("project").await?;
1130
1131            assert!(values.is_empty());
1132        }
1133
1134        #[local_db_sqlx_test]
1135        async fn test_list_split_tag_values_with_data(pool: PgPool) -> Result<(), anyhow::Error> {
1136            let user = USER.get().unwrap();
1137            user.commit()
1138                .await
1139                .expect("Failed to commit user to database");
1140
1141            let commodity_id = user
1142                .create_commodity("USD".to_string(), "US Dollar".to_string())
1143                .await?
1144                .id;
1145            let acc1 = user.create_account("test_acc1", None).await?.id;
1146            let acc2 = user.create_account("test_acc2", None).await?.id;
1147
1148            let tx1 = Transaction {
1149                id: sqlx::types::Uuid::new_v4(),
1150                post_date: chrono::Utc::now(),
1151                enter_date: chrono::Utc::now(),
1152            };
1153            let tx2 = Transaction {
1154                id: sqlx::types::Uuid::new_v4(),
1155                post_date: chrono::Utc::now(),
1156                enter_date: chrono::Utc::now(),
1157            };
1158
1159            let split1_id = sqlx::types::Uuid::new_v4();
1160            let split2_id = sqlx::types::Uuid::new_v4();
1161            let split3_id = sqlx::types::Uuid::new_v4();
1162            let split4_id = sqlx::types::Uuid::new_v4();
1163
1164            {
1165                let mut conn = user.get_connection().await?;
1166                let mut ticket1 = tx1.enter(&mut *conn).await?;
1167                let split1a = Split {
1168                    id: split1_id,
1169                    account_id: acc1,
1170                    tx_id: tx1.id,
1171                    value_num: 100,
1172                    value_denom: 1,
1173                    commodity_id,
1174                    reconcile_state: None,
1175                    reconcile_date: None,
1176                    lot_id: None,
1177                };
1178                let split1b = Split {
1179                    id: split2_id,
1180                    account_id: acc2,
1181                    tx_id: tx1.id,
1182                    value_num: -100,
1183                    value_denom: 1,
1184                    commodity_id,
1185                    reconcile_state: None,
1186                    reconcile_date: None,
1187                    lot_id: None,
1188                };
1189                ticket1.add_splits(&[&split1a, &split1b]).await?;
1190                ticket1.commit().await?;
1191            }
1192            {
1193                let mut conn = user.get_connection().await?;
1194                let mut ticket2 = tx2.enter(&mut *conn).await?;
1195                let split2a = Split {
1196                    id: split3_id,
1197                    account_id: acc1,
1198                    tx_id: tx2.id,
1199                    value_num: 200,
1200                    value_denom: 1,
1201                    commodity_id,
1202                    reconcile_state: None,
1203                    reconcile_date: None,
1204                    lot_id: None,
1205                };
1206                let split2b = Split {
1207                    id: split4_id,
1208                    account_id: acc2,
1209                    tx_id: tx2.id,
1210                    value_num: -200,
1211                    value_denom: 1,
1212                    commodity_id,
1213                    reconcile_state: None,
1214                    reconcile_date: None,
1215                    lot_id: None,
1216                };
1217                ticket2.add_splits(&[&split2a, &split2b]).await?;
1218                ticket2.commit().await?;
1219            }
1220
1221            user.create_split_tag(
1222                split1_id,
1223                "project".to_string(),
1224                "nomisync".to_string(),
1225                None,
1226            )
1227            .await?;
1228            user.create_split_tag(
1229                split3_id,
1230                "project".to_string(),
1231                "website".to_string(),
1232                None,
1233            )
1234            .await?;
1235            user.create_split_tag(
1236                split2_id,
1237                "department".to_string(),
1238                "engineering".to_string(),
1239                None,
1240            )
1241            .await?;
1242
1243            let project_values = user.list_split_tag_values("project").await?;
1244
1245            assert_eq!(project_values.len(), 2);
1246            assert!(project_values.contains(&"nomisync".to_string()));
1247            assert!(project_values.contains(&"website".to_string()));
1248
1249            let department_values = user.list_split_tag_values("department").await?;
1250
1251            assert_eq!(department_values.len(), 1);
1252            assert!(department_values.contains(&"engineering".to_string()));
1253        }
1254
1255        #[local_db_sqlx_test]
1256        async fn test_list_split_tag_values_nonexistent_name(
1257            pool: PgPool,
1258        ) -> Result<(), anyhow::Error> {
1259            let user = USER.get().unwrap();
1260            user.commit()
1261                .await
1262                .expect("Failed to commit user to database");
1263
1264            let commodity_id = user
1265                .create_commodity("USD".to_string(), "US Dollar".to_string())
1266                .await?
1267                .id;
1268            let acc1 = user.create_account("test_acc1", None).await?.id;
1269            let acc2 = user.create_account("test_acc2", None).await?.id;
1270
1271            let tx = Transaction {
1272                id: sqlx::types::Uuid::new_v4(),
1273                post_date: chrono::Utc::now(),
1274                enter_date: chrono::Utc::now(),
1275            };
1276
1277            let mut conn = user.get_connection().await?;
1278            let mut ticket = tx.enter(&mut *conn).await?;
1279            let split1 = Split {
1280                id: sqlx::types::Uuid::new_v4(),
1281                account_id: acc1,
1282                tx_id: tx.id,
1283                value_num: 100,
1284                value_denom: 1,
1285                commodity_id,
1286                reconcile_state: None,
1287                reconcile_date: None,
1288                lot_id: None,
1289            };
1290            let split2 = Split {
1291                id: sqlx::types::Uuid::new_v4(),
1292                account_id: acc2,
1293                tx_id: tx.id,
1294                value_num: -100,
1295                value_denom: 1,
1296                commodity_id,
1297                reconcile_state: None,
1298                reconcile_date: None,
1299                lot_id: None,
1300            };
1301            ticket.add_splits(&[&split1, &split2]).await?;
1302            ticket.commit().await?;
1303
1304            user.create_split_tag(
1305                split1.id,
1306                "project".to_string(),
1307                "nomisync".to_string(),
1308                None,
1309            )
1310            .await?;
1311
1312            let values = user.list_split_tag_values("nonexistent").await?;
1313
1314            assert!(values.is_empty());
1315        }
1316
1317        #[local_db_sqlx_test]
1318        async fn test_create_account_tag(pool: PgPool) -> Result<(), anyhow::Error> {
1319            let user = USER.get().unwrap();
1320            user.commit()
1321                .await
1322                .expect("Failed to commit user to database");
1323
1324            let acc = user.create_account("test_acc", None).await?;
1325
1326            let tag_id = user
1327                .create_account_tag(
1328                    acc.id,
1329                    "category".to_string(),
1330                    "assets".to_string(),
1331                    Some("Account category".to_string()),
1332                )
1333                .await?;
1334
1335            let mut conn = user.get_connection().await?;
1336            let res = sqlx::query!(
1337                "SELECT tag_id FROM account_tags WHERE account_id = $1 AND tag_id = $2",
1338                &acc.id,
1339                &tag_id
1340            )
1341            .fetch_one(&mut *conn)
1342            .await?;
1343
1344            assert_eq!(res.tag_id, tag_id);
1345
1346            let tag = user.get_tag(tag_id).await?;
1347            assert_eq!(tag.tag_name, "category");
1348            assert_eq!(tag.tag_value, "assets");
1349            assert_eq!(tag.description, Some("Account category".to_string()));
1350        }
1351
1352        #[local_db_sqlx_test]
1353        async fn test_list_account_tag_names_empty(pool: PgPool) -> Result<(), anyhow::Error> {
1354            let user = USER.get().unwrap();
1355            user.commit()
1356                .await
1357                .expect("Failed to commit user to database");
1358
1359            let names = user.list_account_tag_names().await?;
1360
1361            assert!(names.is_empty());
1362        }
1363
1364        #[local_db_sqlx_test]
1365        async fn test_list_account_tag_names_with_data(pool: PgPool) -> Result<(), anyhow::Error> {
1366            let user = USER.get().unwrap();
1367            user.commit()
1368                .await
1369                .expect("Failed to commit user to database");
1370
1371            let acc1 = user.create_account("test_acc1", None).await?;
1372            let acc2 = user.create_account("test_acc2", None).await?;
1373
1374            user.set_account_tag(
1375                &acc1,
1376                &Tag {
1377                    id: Uuid::new_v4(),
1378                    tag_name: "category".to_string(),
1379                    tag_value: "assets".to_string(),
1380                    description: None,
1381                },
1382            )
1383            .await?;
1384            user.set_account_tag(
1385                &acc2,
1386                &Tag {
1387                    id: Uuid::new_v4(),
1388                    tag_name: "priority".to_string(),
1389                    tag_value: "high".to_string(),
1390                    description: None,
1391                },
1392            )
1393            .await?;
1394
1395            let names = user.list_account_tag_names().await?;
1396
1397            assert!(names.contains(&"category".to_string()));
1398            assert!(names.contains(&"priority".to_string()));
1399        }
1400
1401        #[local_db_sqlx_test]
1402        async fn test_list_account_tag_values_empty(pool: PgPool) -> Result<(), anyhow::Error> {
1403            let user = USER.get().unwrap();
1404            user.commit()
1405                .await
1406                .expect("Failed to commit user to database");
1407
1408            let values = user.list_account_tag_values("category").await?;
1409
1410            assert!(values.is_empty());
1411        }
1412
1413        #[local_db_sqlx_test]
1414        async fn test_list_account_tag_values_with_data(pool: PgPool) -> Result<(), anyhow::Error> {
1415            let user = USER.get().unwrap();
1416            user.commit()
1417                .await
1418                .expect("Failed to commit user to database");
1419
1420            let acc1 = user.create_account("test_acc1", None).await?;
1421            let acc2 = user.create_account("test_acc2", None).await?;
1422
1423            user.set_account_tag(
1424                &acc1,
1425                &Tag {
1426                    id: Uuid::new_v4(),
1427                    tag_name: "category".to_string(),
1428                    tag_value: "assets".to_string(),
1429                    description: None,
1430                },
1431            )
1432            .await?;
1433            user.set_account_tag(
1434                &acc2,
1435                &Tag {
1436                    id: Uuid::new_v4(),
1437                    tag_name: "category".to_string(),
1438                    tag_value: "liabilities".to_string(),
1439                    description: None,
1440                },
1441            )
1442            .await?;
1443
1444            let values = user.list_account_tag_values("category").await?;
1445
1446            assert_eq!(values.len(), 2);
1447            assert!(values.contains(&"assets".to_string()));
1448            assert!(values.contains(&"liabilities".to_string()));
1449
1450            let nonexistent = user.list_account_tag_values("nonexistent").await?;
1451
1452            assert!(nonexistent.is_empty());
1453        }
1454    }
1455}