1use finance::{tag::Tag, transaction::Transaction};
2use num_rational::Rational64;
3use scripting::ScriptExecutor;
4use sqlx::{
5 Acquire,
6 types::Uuid,
7 types::chrono::{DateTime, Utc},
8};
9use std::{collections::HashMap, fmt::Debug};
10use supp_macro::command;
11
12pub(super) struct SplitAmountRow {
13 pub(super) tx_id: Uuid,
14 pub(super) commodity_id: Uuid,
15 pub(super) value_num: i64,
16 pub(super) value_denom: i64,
17 pub(super) symbol: String,
18}
19
20pub(super) fn aggregate_split_amounts(rows: Vec<SplitAmountRow>) -> HashMap<Uuid, String> {
27 let mut tx_map: HashMap<Uuid, HashMap<Uuid, (String, Rational64)>> = HashMap::new();
28 for row in rows {
29 if row.value_denom == 0 {
30 continue;
31 }
32 let r = Rational64::new(row.value_num, row.value_denom);
33 let entry = tx_map
34 .entry(row.tx_id)
35 .or_default()
36 .entry(row.commodity_id)
37 .or_insert_with(|| (row.symbol, Rational64::from(0)));
38 entry.1 += r;
39 }
40 tx_map
41 .into_iter()
42 .map(|(tx_id, commodities)| {
43 let mut pairs: Vec<(Uuid, String, Rational64)> = commodities
44 .into_iter()
45 .map(|(commodity_id, (symbol, amount))| (commodity_id, symbol, amount))
46 .collect();
47 pairs.sort_by(|a, b| a.1.cmp(&b.1).then(a.0.cmp(&b.0)));
48 let formatted = pairs
49 .into_iter()
50 .map(|(_, symbol, amount)| format!("{} {}", format_split_amount(amount), symbol))
51 .collect::<Vec<_>>()
52 .join("; ");
53 (tx_id, formatted)
54 })
55 .collect()
56}
57
58fn format_split_amount(r: Rational64) -> String {
59 if *r.denom() == 1 {
60 r.numer().to_string()
61 } else {
62 format!("{}/{}", r.numer(), r.denom())
63 }
64}
65
66async fn load_split_amounts(
68 conn: &mut sqlx::PgConnection,
69 tx_ids: &[Uuid],
70) -> Result<HashMap<Uuid, String>, sqlx::Error> {
71 if tx_ids.is_empty() {
72 return Ok(HashMap::new());
73 }
74 let rows = sqlx::query_file!("sql/select/splits/for_list.sql", tx_ids)
75 .fetch_all(conn)
76 .await?
77 .into_iter()
78 .map(|r| SplitAmountRow {
79 tx_id: r.tx_id,
80 commodity_id: r.commodity_id,
81 value_num: r.value_num,
82 value_denom: r.value_denom,
83 symbol: r.symbol,
84 })
85 .collect();
86 Ok(aggregate_split_amounts(rows))
87}
88
89use crate::script::TransactionState;
90use crate::{config::ConfigError, user::User};
91
92use super::{CmdError, CmdResult, FinanceEntity, PaginationInfo};
93
94command! {
95 CreateTransaction {
96 #[required]
97 user_id: Uuid,
98 #[required]
99 splits: Vec<FinanceEntity>,
100 #[required]
101 id: Uuid,
102 #[required]
103 post_date: DateTime<Utc>,
104 #[required]
105 enter_date: DateTime<Utc>,
106 #[optional]
107 prices: Vec<FinanceEntity>,
108 #[optional]
109 note: String,
110 #[optional]
111 split_tags: Vec<(Uuid, Tag)>,
112 } => {
113
114
115 let user = User { id: user_id };
116
117 let mut conn = user.get_connection().await.map_err(|err| {
118 log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
119 ConfigError::DB
120 })?;
121
122 let tx = Transaction {
123 id,
124 post_date,
125 enter_date,
126 };
127
128 let (transaction, splits, prices, transaction_tags, split_tags) = {
129 let scripts: Vec<(Uuid, Vec<u8>)> = sqlx::query_file!("sql/select/artifacts/enabled.sql")
130 .fetch_all(&mut *conn)
131 .await?
132 .into_iter()
133 .map(|row| (row.id, row.bytecode))
134 .collect();
135
136 let state = TransactionState::new(tx)
137 .with(splits)
138 .with(prices.unwrap_or_default())
139 .with_note(note)
140 .with_split_tags(split_tags.unwrap_or_default());
141
142 let state = if scripts.is_empty() {
143 state
144 } else {
145 let report = tokio::task::spawn_blocking(move || {
146 let executor = ScriptExecutor::try_new()?;
147 state.run_scripts(&executor, &scripts)
148 })
149 .await
150 .map_err(|e| CmdError::Script(format!("{e:?}")))?
151 .map_err(|e| {
152 log::error!("{}", t!("Script execution failed: %{err}", err = e : {:?}));
153 CmdError::Script(format!("{e:?}"))
154 })?;
155 for failure in &report.failures {
156 log::error!(
157 "{}",
158 t!(
159 "Script %{id} failed: %{code}: %{message}",
160 id = failure.script_id,
161 code = failure.code,
162 message = failure.message
163 )
164 );
165 }
166 report.state
167 };
168
169 (state.transaction, state.splits, state.prices, state.transaction_tags, state.split_tags)
170 };
171
172 let mut ticket = transaction.enter(&mut *conn).await?;
174
175 let split_refs: Vec<_> = splits.iter().collect();
176 ticket.add_splits(&split_refs).await?;
177
178 if !prices.is_empty() {
182 let valid_split_ids: std::collections::HashSet<Uuid> =
183 splits.iter().map(|split| split.id).collect();
184 for price in &prices {
185 for split_id in [price.commodity_split, price.currency_split]
186 .into_iter()
187 .flatten()
188 {
189 if !valid_split_ids.contains(&split_id) {
190 return Err(CmdError::Args(
191 "Price references a split that is not part of this transaction"
192 .to_string(),
193 ));
194 }
195 }
196 }
197 let price_refs: Vec<_> = prices.iter().collect();
198 ticket.add_conversions(&price_refs).await?;
199 }
200
201 if !transaction_tags.is_empty() {
202 let tag_refs: Vec<_> = transaction_tags.iter().collect();
203 ticket.add_tags(&tag_refs).await?;
204 }
205
206 if !split_tags.is_empty() {
207 ticket.add_split_tags(&split_tags).await?;
208 }
209
210 ticket.commit().await?;
211
212 Ok(Some(CmdResult::Entity(FinanceEntity::Transaction(transaction))))
213 }
214}
215
216command! {
217 ListTransactions {
218 #[required]
219 user_id: Uuid,
220 #[optional]
221 account: Uuid,
222 #[optional]
223 limit: i64,
224 #[optional]
225 offset: i64,
226 #[optional]
227 date_from: DateTime<Utc>,
228 #[optional]
229 date_to: DateTime<Utc>,
230 } => {
231 let user = User { id: user_id };
232 let mut conn = user.get_connection().await.map_err(|err| {
233 log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
234 ConfigError::DB
235 })?;
236
237 let account_uuid = account.as_ref();
238 let effective_limit = limit.unwrap_or(20);
239 let effective_offset = offset.unwrap_or(0);
240 let date_from_ref = date_from.as_ref();
241 let date_to_ref = date_to.as_ref();
242
243 let count_result = sqlx::query_file!(
244 "sql/count/transactions/filtered.sql",
245 account_uuid,
246 date_from_ref,
247 date_to_ref
248 )
249 .fetch_one(&mut *conn)
250 .await?;
251
252 let total_count = count_result.count.unwrap_or(0);
253
254 let transactions = sqlx::query_file!(
255 "sql/select/transactions/paginated.sql",
256 account_uuid,
257 date_from_ref,
258 date_to_ref,
259 effective_limit,
260 effective_offset
261 )
262 .fetch_all(&mut *conn)
263 .await?;
264
265 let tx_ids: Vec<Uuid> = transactions.iter().map(|r| r.id).collect();
266 let split_amounts = load_split_amounts(&mut conn, &tx_ids).await?;
267
268 let mut tagged_transactions = Vec::new();
269 for tx_row in transactions {
270 let transaction = Transaction {
271 id: tx_row.id,
272 post_date: tx_row.post_date,
273 enter_date: tx_row.enter_date,
274 };
275
276 let tags: HashMap<String, FinanceEntity> =
277 sqlx::query_file!("sql/select/tags/by_transaction.sql", &transaction.id)
278 .fetch_all(&mut *conn)
279 .await?
280 .into_iter()
281 .map(|row| {
282 (
283 row.tag_name.clone(),
284 FinanceEntity::Tag(Tag {
285 id: row.id,
286 tag_name: row.tag_name,
287 tag_value: row.tag_value,
288 description: row.description,
289 }),
290 )
291 })
292 .collect();
293
294 let amount = split_amounts.get(&transaction.id).cloned();
295 tagged_transactions.push((FinanceEntity::Transaction(transaction), tags, amount));
296 }
297
298 let pagination = PaginationInfo {
299 total_count,
300 limit: effective_limit,
301 offset: effective_offset,
302 has_more: effective_offset + (tagged_transactions.len() as i64) < total_count,
303 };
304
305 Ok(Some(CmdResult::TaggedTransactions {
306 entities: tagged_transactions,
307 pagination: Some(pagination),
308 }))
309 }
310}
311
312command! {
313 GetTransaction {
314 #[required]
315 user_id: Uuid,
316 #[required]
317 transaction_id: Uuid,
318 } => {
319 let user = User { id: user_id };
320 let mut conn = user.get_connection().await.map_err(|err| {
321 log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
322 ConfigError::DB
323 })?;
324
325 let tx_row = sqlx::query_file!("sql/select/transactions/by_id.sql", transaction_id)
326 .fetch_optional(&mut *conn)
327 .await?;
328
329 if let Some(tx_row) = tx_row {
330 let transaction = Transaction {
331 id: tx_row.id,
332 post_date: tx_row.post_date,
333 enter_date: tx_row.enter_date,
334 };
335
336 let tags: HashMap<String, FinanceEntity> =
337 sqlx::query_file!("sql/select/tags/by_transaction.sql", &transaction.id)
338 .fetch_all(&mut *conn)
339 .await?
340 .into_iter()
341 .map(|row| {
342 (
343 row.tag_name.clone(),
344 FinanceEntity::Tag(Tag {
345 id: row.id,
346 tag_name: row.tag_name,
347 tag_value: row.tag_value,
348 description: row.description,
349 }),
350 )
351 })
352 .collect();
353
354 let amount = load_split_amounts(&mut conn, &[transaction.id])
355 .await?
356 .remove(&transaction.id);
357
358 Ok(Some(CmdResult::TaggedTransactions {
359 entities: vec![(FinanceEntity::Transaction(transaction), tags, amount)],
360 pagination: None,
361 }))
362 } else {
363 Ok(None)
364 }
365 }
366}
367
368command! {
369 GetTransactionDetail {
370 #[required]
371 user_id: Uuid,
372 #[required]
373 transaction_id: Uuid,
374 } => {
375 let user = User { id: user_id };
376 let mut conn = user.get_connection().await.map_err(|err| {
377 log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
378 ConfigError::DB
379 })?;
380
381 let tx_row = sqlx::query_file!("sql/select/transactions/by_id.sql", transaction_id)
382 .fetch_optional(&mut *conn)
383 .await?;
384
385 if let Some(tx_row) = tx_row {
386 let transaction = Transaction {
387 id: tx_row.id,
388 post_date: tx_row.post_date,
389 enter_date: tx_row.enter_date,
390 };
391
392 let tags: HashMap<String, FinanceEntity> =
393 sqlx::query_file!("sql/select/tags/by_transaction.sql", &transaction.id)
394 .fetch_all(&mut *conn)
395 .await?
396 .into_iter()
397 .map(|row| {
398 (
399 row.tag_name.clone(),
400 FinanceEntity::Tag(Tag {
401 id: row.id,
402 tag_name: row.tag_name,
403 tag_value: row.tag_value,
404 description: row.description,
405 }),
406 )
407 })
408 .collect();
409
410 let split_entities: Vec<(FinanceEntity, HashMap<String, FinanceEntity>)> =
411 sqlx::query_file!("sql/select/splits/by_transaction.sql", transaction_id)
412 .fetch_all(&mut *conn)
413 .await?
414 .into_iter()
415 .map(|row| {
416 (
417 FinanceEntity::Split(finance::split::Split {
418 id: row.id,
419 tx_id: row.tx_id,
420 account_id: row.account_id,
421 commodity_id: row.commodity_id,
422 value_num: row.value_num,
423 value_denom: row.value_denom,
424 reconcile_state: row.reconcile_state,
425 reconcile_date: row.reconcile_date,
426 lot_id: row.lot_id,
427 }),
428 HashMap::new(),
429 )
430 })
431 .collect();
432
433 let price_entities: Vec<(FinanceEntity, HashMap<String, FinanceEntity>)> =
434 sqlx::query_file!("sql/select/prices/by_transaction.sql", transaction_id)
435 .fetch_all(&mut *conn)
436 .await?
437 .into_iter()
438 .map(|row| {
439 (
440 FinanceEntity::Price(finance::price::Price {
441 id: row.id,
442 date: row.price_date,
443 commodity_id: row.commodity_id,
444 currency_id: row.currency_id,
445 commodity_split: row.commodity_split_id,
446 currency_split: row.currency_split_id,
447 value_num: row.value_num,
448 value_denom: row.value_denom,
449 }),
450 HashMap::new(),
451 )
452 })
453 .collect();
454
455 let mut entities: Vec<(FinanceEntity, HashMap<String, FinanceEntity>)> =
456 vec![(FinanceEntity::Transaction(transaction), tags)];
457 entities.extend(split_entities);
458 entities.extend(price_entities);
459
460 Ok(Some(CmdResult::TaggedEntities {
461 entities,
462 pagination: None,
463 }))
464 } else {
465 Ok(None)
466 }
467 }
468}
469
470command! {
471 UpdateTransaction {
472 #[required]
473 user_id: Uuid,
474 #[required]
475 transaction_id: Uuid,
476 #[optional]
477 splits: Vec<FinanceEntity>,
478 #[optional]
479 post_date: DateTime<Utc>,
480 #[optional]
481 enter_date: DateTime<Utc>,
482 #[optional]
483 note: String,
484 #[optional]
485 prices: Vec<FinanceEntity>,
486 #[optional]
487 tags: HashMap<String, FinanceEntity>,
488 } => {
489 let user = User { id: user_id };
490 let mut conn = user.get_connection().await.map_err(|err| {
491 log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
492 ConfigError::DB
493 })?;
494
495 let mut tx = conn.begin().await?;
496
497 let existing = sqlx::query_file!("sql/select/transactions/by_id.sql", transaction_id)
498 .fetch_optional(&mut *tx)
499 .await?
500 .ok_or_else(|| CmdError::Args("Transaction not found".to_string()))?;
501
502 let final_post_date = post_date.unwrap_or(existing.post_date);
503 let final_enter_date = enter_date.unwrap_or(existing.enter_date);
504
505 let mut new_split_ids: Option<std::collections::HashSet<Uuid>> = None;
509
510 if let Some(ref new_splits) = splits {
512 let mut commodity_sums: std::collections::HashMap<Uuid, num_rational::Rational64> =
513 std::collections::HashMap::new();
514 let mut ids = std::collections::HashSet::new();
515
516 for entity in new_splits {
517 if let FinanceEntity::Split(split) = entity {
518 if split.tx_id != transaction_id {
520 return Err(CmdError::Args("Split transaction ID mismatch".to_string()));
521 }
522 ids.insert(split.id);
523
524 let split_value =
525 num_rational::Rational64::new(split.value_num, split.value_denom);
526 *commodity_sums.entry(split.commodity_id).or_insert(
527 num_rational::Rational64::new(0, 1),
528 ) += split_value;
529 } else {
530 return Err(CmdError::Args("Invalid entity type in splits".to_string()));
531 }
532 }
533
534 if commodity_sums.len() == 1 {
538 for sum in commodity_sums.values() {
539 if *sum != num_rational::Rational64::new(0, 1) {
540 return Err(CmdError::Args("Splits must sum to zero".to_string()));
541 }
542 }
543 }
544
545 new_split_ids = Some(ids);
546 }
547
548 sqlx::query_file!(
550 "sql/update/transactions/update.sql",
551 transaction_id,
552 final_post_date,
553 final_enter_date
554 )
555 .execute(&mut *tx)
556 .await?;
557
558 if let Some(new_splits) = splits {
560 sqlx::query_file!("sql/delete/split_tags/by_transaction.sql", transaction_id)
562 .execute(&mut *tx)
563 .await?;
564
565 sqlx::query_file!("sql/delete/prices/by_splits.sql", transaction_id)
566 .execute(&mut *tx)
567 .await?;
568
569 sqlx::query_file!("sql/delete/splits/by_transaction.sql", transaction_id)
570 .execute(&mut *tx)
571 .await?;
572
573 for entity in new_splits {
575 if let FinanceEntity::Split(split) = entity {
576 sqlx::query_file!(
577 "sql/insert/splits/split.sql",
578 split.id,
579 split.tx_id,
580 split.account_id,
581 split.commodity_id,
582 split.reconcile_state,
583 split.reconcile_date,
584 split.value_num,
585 split.value_denom,
586 split.lot_id
587 )
588 .execute(&mut *tx)
589 .await?;
590 }
591 }
592 }
593
594 if let Some(ref new_prices) = prices {
598 let valid_split_ids: std::collections::HashSet<Uuid> = match &new_split_ids {
599 Some(ids) => ids.clone(),
600 None => sqlx::query_file!("sql/select/splits/by_transaction.sql", transaction_id)
601 .fetch_all(&mut *tx)
602 .await?
603 .into_iter()
604 .map(|row| row.id)
605 .collect(),
606 };
607 for entity in new_prices {
608 let FinanceEntity::Price(price) = entity else {
609 return Err(CmdError::Args("Invalid entity type in prices".to_string()));
610 };
611 for split_id in [price.commodity_split, price.currency_split].into_iter().flatten() {
614 if !valid_split_ids.contains(&split_id) {
615 return Err(CmdError::Args(
616 "Price references a split that is not part of this transaction"
617 .to_string(),
618 ));
619 }
620 }
621 }
622 }
623
624 if let Some(ref new_tags) = tags {
626 for entity in new_tags.values() {
627 if let FinanceEntity::Tag(_) = entity {
628 } else {
630 return Err(CmdError::Args("Invalid entity type in tags".to_string()));
631 }
632 }
633 }
634
635 if let Some(new_prices) = prices {
637 for entity in new_prices {
638 if let FinanceEntity::Price(price) = entity {
639 sqlx::query_file!(
640 "sql/insert/prices/price.sql",
641 price.id,
642 price.commodity_id,
643 price.currency_id,
644 price.commodity_split,
645 price.currency_split,
646 price.date,
647 price.value_num,
648 price.value_denom
649 )
650 .execute(&mut *tx)
651 .await?;
652 }
653 }
654 }
655
656 if let Some(new_tags) = tags {
658 sqlx::query_file!("sql/delete/transaction_tags/by_transaction.sql", transaction_id)
659 .execute(&mut *tx)
660 .await?;
661
662 for (_, entity) in new_tags {
663 if let FinanceEntity::Tag(tag) = entity {
664 sqlx::query_file!(
665 "sql/insert/transaction_tags/transaction_tag.sql",
666 transaction_id,
667 tag.id
668 )
669 .execute(&mut *tx)
670 .await?;
671 }
672 }
673 }
674
675 if let Some(note_value) = note {
677 sqlx::query!("DELETE FROM transaction_tags WHERE tx_id = $1 AND tag_id IN (SELECT id FROM tags WHERE tag_name = 'note')", transaction_id)
679 .execute(&mut *tx)
680 .await?;
681
682 if !note_value.trim().is_empty() {
683 let note_tag_id = Tag {
684 id: Uuid::new_v4(),
685 tag_name: "note".to_string(),
686 tag_value: note_value,
687 description: None,
688 }
689 .commit(&mut *tx)
690 .await?;
691
692 sqlx::query_file!(
693 "sql/insert/transaction_tags/transaction_tag.sql",
694 transaction_id,
695 note_tag_id
696 )
697 .execute(&mut *tx)
698 .await?;
699 }
700 }
701
702 tx.commit().await?;
703
704 let updated_transaction = Transaction {
705 id: transaction_id,
706 post_date: final_post_date,
707 enter_date: final_enter_date,
708 };
709
710 Ok(Some(CmdResult::Entity(FinanceEntity::Transaction(updated_transaction))))
711 }
712}
713
714command! {
715 DeleteTransaction {
716 #[required]
717 user_id: Uuid,
718 #[required]
719 transaction_id: Uuid,
720 } => {
721 let user = User { id: user_id };
722 let mut conn = user.get_connection().await.map_err(|err| {
723 log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
724 ConfigError::DB
725 })?;
726
727 sqlx::query_file!("sql/select/transactions/by_id.sql", transaction_id)
728 .fetch_optional(&mut *conn)
729 .await?
730 .ok_or_else(|| CmdError::Args("Transaction not found".to_string()))?;
731
732 let mut tx = conn.begin().await?;
733
734 let tag_ids_to_check: Vec<Uuid> = sqlx::query_file!(
735 "sql/select/tags/by_transaction_and_splits.sql",
736 transaction_id
737 )
738 .fetch_all(&mut *tx)
739 .await?
740 .into_iter()
741 .filter_map(|row| row.tag_id)
742 .collect();
743
744 sqlx::query_file!("sql/delete/prices/by_splits.sql", transaction_id)
745 .execute(&mut *tx)
746 .await?;
747
748 sqlx::query_file!("sql/delete/split_tags/by_transaction.sql", transaction_id)
749 .execute(&mut *tx)
750 .await?;
751
752 sqlx::query_file!("sql/delete/transaction_tags/by_transaction.sql", transaction_id)
753 .execute(&mut *tx)
754 .await?;
755
756 for tag_id in tag_ids_to_check {
757 let is_orphaned = sqlx::query_file!("sql/check/tags/is_orphaned.sql", tag_id)
758 .fetch_one(&mut *tx)
759 .await?
760 .is_orphaned
761 .unwrap_or(false);
762
763 if is_orphaned {
764 sqlx::query_file!("sql/delete/tags/by_id.sql", tag_id)
765 .execute(&mut *tx)
766 .await?;
767 }
768 }
769
770 sqlx::query_file!("sql/delete/splits/by_transaction.sql", transaction_id)
771 .execute(&mut *tx)
772 .await?;
773
774 sqlx::query_file!("sql/delete/transactions/by_id.sql", transaction_id)
775 .execute(&mut *tx)
776 .await?;
777
778 tx.commit().await?;
779
780 Ok(Some(CmdResult::String("Transaction deleted successfully".to_string())))
781 }
782}
783
784command! {
788 SetTransactionTag {
789 #[required]
790 user_id: Uuid,
791 #[required]
792 transaction_id: Uuid,
793 #[required]
794 tag_name: String,
795 #[required]
796 tag_value: String,
797 #[optional]
798 description: String,
799 } => {
800 let user = User { id: user_id };
801 let desc = description.and_then(|text| {
802 if text.trim().is_empty() {
803 None
804 } else {
805 Some(text)
806 }
807 });
808 let tag = Tag {
809 id: Uuid::new_v4(),
810 tag_name,
811 tag_value,
812 description: desc,
813 };
814 user.set_transaction_tag(transaction_id, &tag)
815 .await
816 .map_err(|err| {
817 log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
818 CmdError::Args(format!("{err:?}"))
819 })?;
820 Ok(Some(CmdResult::String("ok".to_string())))
821 }
822}
823
824command! {
827 GetTransactionTag {
828 #[required]
829 user_id: Uuid,
830 #[required]
831 transaction_id: Uuid,
832 #[required]
833 tag_name: String,
834 } => {
835 let user = User { id: user_id };
836 let tags = user
837 .get_transaction_tags(transaction_id)
838 .await
839 .map_err(|err| {
840 log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
841 CmdError::Args(format!("{err:?}"))
842 })?;
843 let value = tags
844 .into_iter()
845 .find(|t| t.tag_name == tag_name)
846 .map(|t| t.tag_value)
847 .unwrap_or_default();
848 Ok(Some(CmdResult::String(value)))
849 }
850}
851
852#[cfg(test)]
853mod command_tests {
854 use super::*;
855 use crate::{
856 command::{account::CreateAccount, commodity::CreateCommodity},
857 db::DB_POOL,
858 };
859 use chrono::Duration;
860 use finance::{account::Account, price::Price, split::Split};
861 use sqlx::PgPool;
862 use supp_macro::local_db_sqlx_test;
863 use tokio::sync::OnceCell;
864
865 static CONTEXT: OnceCell<()> = OnceCell::const_new();
867 static USER: OnceCell<User> = OnceCell::const_new();
868
869 async fn setup() {
870 CONTEXT
871 .get_or_init(|| async {
872 #[cfg(feature = "testlog")]
873 let _ = env_logger::builder()
874 .is_test(true)
875 .filter_level(log::LevelFilter::Trace)
876 .try_init();
877 })
878 .await;
879 USER.get_or_init(|| async { User { id: Uuid::new_v4() } })
880 .await;
881 }
882
883 #[local_db_sqlx_test]
884 async fn test_create_transaction(pool: PgPool) -> anyhow::Result<()> {
885 let user = USER.get().unwrap();
886 user.commit()
887 .await
888 .expect("Failed to commit user to database");
889
890 let commodity_result = CreateCommodity::new()
892 .symbol("TST".to_string())
893 .name("Test Commodity".to_string())
894 .user_id(user.id)
895 .run()
896 .await?;
897
898 let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
900 uuid::Uuid::parse_str(&id)?
901 } else {
902 panic!("Expected commodity ID string result");
903 };
904
905 let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
907 CreateAccount::new()
908 .name("Account 1".to_string())
909 .user_id(user.id)
910 .run()
911 .await?
912 {
913 account
914 } else {
915 panic!("Expected account entity result");
916 };
917
918 let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
919 CreateAccount::new()
920 .name("Account 2".to_string())
921 .user_id(user.id)
922 .run()
923 .await?
924 {
925 account
926 } else {
927 panic!("Expected account entity result");
928 };
929
930 let tx_id = Uuid::new_v4();
931
932 let split1 = Split::builder()
934 .id(Uuid::new_v4())
935 .tx_id(tx_id)
936 .account_id(account1.id)
937 .commodity_id(commodity_id)
938 .value_num(100)
939 .value_denom(1)
940 .build()?;
941
942 let split2 = Split::builder()
943 .id(Uuid::new_v4())
944 .tx_id(tx_id)
945 .account_id(account2.id)
946 .commodity_id(commodity_id)
947 .value_num(-100)
948 .value_denom(1)
949 .build()?;
950
951 let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
953 let now = Utc::now();
954
955 if let Some(CmdResult::Entity(FinanceEntity::Transaction(tx))) = CreateTransaction::new()
956 .user_id(user.id)
957 .splits(splits)
958 .id(tx_id)
959 .post_date(now)
960 .enter_date(now)
961 .run()
962 .await?
963 {
964 assert!(!tx.id.is_nil());
965
966 let mut conn = user.get_connection().await?;
968 let splits = sqlx::query_file!("sql/count/splits/by_transaction.sql", tx.id)
969 .fetch_one(&mut *conn)
970 .await?;
971 assert_eq!(splits.count, Some(2));
972 } else {
973 panic!("Expected transaction entity result");
974 }
975 }
976 #[local_db_sqlx_test]
977 async fn test_list_transactions_empty(pool: PgPool) -> anyhow::Result<()> {
978 let user = USER.get().unwrap();
979 user.commit()
980 .await
981 .expect("Failed to commit user to database");
982
983 if let Some(CmdResult::TaggedTransactions {
984 entities,
985 pagination: Some(pagination),
986 }) = ListTransactions::new().user_id(user.id).run().await?
987 {
988 assert!(
989 entities.is_empty(),
990 "Expected no transactions in empty database"
991 );
992 assert_eq!(pagination.total_count, 0);
993 assert_eq!(pagination.limit, 20);
994 assert_eq!(pagination.offset, 0);
995 assert!(!pagination.has_more);
996 } else {
997 panic!("Expected TaggedTransactions result with pagination");
998 }
999 }
1000
1001 #[local_db_sqlx_test]
1002 async fn test_list_transactions_with_data(pool: PgPool) -> anyhow::Result<()> {
1003 let user = USER.get().unwrap();
1004 user.commit()
1005 .await
1006 .expect("Failed to commit user to database");
1007
1008 let commodity_result = CreateCommodity::new()
1010 .symbol("TST".to_string())
1011 .name("Test Commodity".to_string())
1012 .user_id(user.id)
1013 .run()
1014 .await?;
1015
1016 let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
1018 uuid::Uuid::parse_str(&id)?
1019 } else {
1020 panic!("Expected commodity ID string result");
1021 };
1022
1023 let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
1025 CreateAccount::new()
1026 .name("Account 1".to_string())
1027 .user_id(user.id)
1028 .run()
1029 .await?
1030 {
1031 account
1032 } else {
1033 panic!("Expected account entity result");
1034 };
1035
1036 let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
1037 CreateAccount::new()
1038 .name("Account 2".to_string())
1039 .user_id(user.id)
1040 .run()
1041 .await?
1042 {
1043 account
1044 } else {
1045 panic!("Expected account entity result");
1046 };
1047
1048 let tx_id = Uuid::new_v4();
1050 let now = Utc::now();
1051
1052 let split1 = Split {
1053 id: Uuid::new_v4(),
1054 tx_id,
1055 account_id: account1.id,
1056 commodity_id,
1057 value_num: -100,
1058 value_denom: 1,
1059 reconcile_state: None,
1060 reconcile_date: None,
1061 lot_id: None,
1062 };
1063
1064 let split2 = Split {
1065 id: Uuid::new_v4(),
1066 tx_id,
1067 account_id: account2.id,
1068 commodity_id,
1069 value_num: 100,
1070 value_denom: 1,
1071 reconcile_state: None,
1072 reconcile_date: None,
1073 lot_id: None,
1074 };
1075
1076 let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
1077 CreateTransaction::new()
1078 .user_id(user.id)
1079 .splits(splits)
1080 .id(tx_id)
1081 .post_date(now)
1082 .enter_date(now)
1083 .run()
1084 .await?;
1085
1086 if let Some(CmdResult::TaggedTransactions {
1088 entities,
1089 pagination: Some(pagination),
1090 }) = ListTransactions::new().user_id(user.id).run().await?
1091 {
1092 assert_eq!(entities.len(), 1, "Expected one transaction");
1093 assert_eq!(pagination.total_count, 1);
1094
1095 let (entity, _tags, _amount) = &entities[0];
1096 if let FinanceEntity::Transaction(tx) = entity {
1097 assert_eq!(tx.id, tx_id);
1098 } else {
1099 panic!("Expected Transaction entity");
1100 }
1101 } else {
1102 panic!("Expected TaggedTransactions result with pagination");
1103 }
1104
1105 if let Some(CmdResult::TaggedTransactions { entities, .. }) = ListTransactions::new()
1107 .user_id(user.id)
1108 .account(account1.id)
1109 .run()
1110 .await?
1111 {
1112 assert_eq!(entities.len(), 1, "Expected one transaction for account1");
1113 } else {
1114 panic!("Expected TaggedTransactions result");
1115 }
1116
1117 if let Some(CmdResult::TaggedTransactions { entities, .. }) = ListTransactions::new()
1119 .user_id(user.id)
1120 .account(Uuid::new_v4())
1121 .run()
1122 .await?
1123 {
1124 assert_eq!(
1125 entities.len(),
1126 0,
1127 "Expected no transactions for non-existent account"
1128 );
1129 } else {
1130 panic!("Expected TaggedTransactions result");
1131 }
1132 }
1133
1134 #[local_db_sqlx_test]
1135 async fn test_get_transaction(pool: PgPool) -> anyhow::Result<()> {
1136 let user = USER.get().unwrap();
1137 user.commit()
1138 .await
1139 .expect("Failed to commit user to database");
1140
1141 let commodity_result = CreateCommodity::new()
1143 .symbol("TST".to_string())
1144 .name("Test Commodity".to_string())
1145 .user_id(user.id)
1146 .run()
1147 .await?;
1148
1149 let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
1150 uuid::Uuid::parse_str(&id)?
1151 } else {
1152 panic!("Expected commodity ID string result");
1153 };
1154
1155 let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
1157 CreateAccount::new()
1158 .name("Account 1".to_string())
1159 .user_id(user.id)
1160 .run()
1161 .await?
1162 {
1163 account
1164 } else {
1165 panic!("Expected account entity result");
1166 };
1167
1168 let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
1169 CreateAccount::new()
1170 .name("Account 2".to_string())
1171 .user_id(user.id)
1172 .run()
1173 .await?
1174 {
1175 account
1176 } else {
1177 panic!("Expected account entity result");
1178 };
1179
1180 let tx_id = Uuid::new_v4();
1182 let split1 = Split {
1183 id: Uuid::new_v4(),
1184 tx_id,
1185 account_id: account1.id,
1186 commodity_id,
1187 value_num: -100,
1188 value_denom: 1,
1189 reconcile_state: None,
1190 reconcile_date: None,
1191 lot_id: None,
1192 };
1193 let split2 = Split {
1194 id: Uuid::new_v4(),
1195 tx_id,
1196 account_id: account2.id,
1197 commodity_id,
1198 value_num: 100,
1199 value_denom: 1,
1200 reconcile_state: None,
1201 reconcile_date: None,
1202 lot_id: None,
1203 };
1204 let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
1205 let now = Utc::now();
1206
1207 CreateTransaction::new()
1208 .user_id(user.id)
1209 .splits(splits)
1210 .id(tx_id)
1211 .post_date(now)
1212 .enter_date(now)
1213 .note("Test transaction".to_string())
1214 .run()
1215 .await?;
1216
1217 if let Some(CmdResult::TaggedTransactions { entities, .. }) = GetTransaction::new()
1219 .user_id(user.id)
1220 .transaction_id(tx_id)
1221 .run()
1222 .await?
1223 {
1224 assert_eq!(entities.len(), 1, "Expected one transaction");
1225 let (entity, _tags, _amount) = &entities[0];
1226 if let FinanceEntity::Transaction(tx) = entity {
1227 assert_eq!(tx.id, tx_id);
1228 } else {
1229 panic!("Expected Transaction entity");
1230 }
1231 } else {
1232 panic!("Expected TaggedTransactions result");
1233 }
1234
1235 let result = GetTransaction::new()
1237 .user_id(user.id)
1238 .transaction_id(Uuid::new_v4())
1239 .run()
1240 .await?;
1241 assert!(
1242 result.is_none(),
1243 "Expected None for non-existent transaction"
1244 );
1245 }
1246
1247 #[local_db_sqlx_test]
1248 async fn test_update_transaction(pool: PgPool) -> anyhow::Result<()> {
1249 let user = USER.get().unwrap();
1250 user.commit()
1251 .await
1252 .expect("Failed to commit user to database");
1253
1254 let commodity_result = CreateCommodity::new()
1256 .symbol("TST".to_string())
1257 .name("Test Commodity".to_string())
1258 .user_id(user.id)
1259 .run()
1260 .await?;
1261
1262 let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
1263 uuid::Uuid::parse_str(&id)?
1264 } else {
1265 panic!("Expected commodity ID string result");
1266 };
1267
1268 let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
1270 CreateAccount::new()
1271 .name("Account 1".to_string())
1272 .user_id(user.id)
1273 .run()
1274 .await?
1275 {
1276 account
1277 } else {
1278 panic!("Expected account entity result");
1279 };
1280
1281 let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
1282 CreateAccount::new()
1283 .name("Account 2".to_string())
1284 .user_id(user.id)
1285 .run()
1286 .await?
1287 {
1288 account
1289 } else {
1290 panic!("Expected account entity result");
1291 };
1292
1293 let tx_id = Uuid::new_v4();
1295 let split1 = Split {
1296 id: Uuid::new_v4(),
1297 tx_id,
1298 account_id: account1.id,
1299 commodity_id,
1300 value_num: -100,
1301 value_denom: 1,
1302 reconcile_state: None,
1303 reconcile_date: None,
1304 lot_id: None,
1305 };
1306 let split2 = Split {
1307 id: Uuid::new_v4(),
1308 tx_id,
1309 account_id: account2.id,
1310 commodity_id,
1311 value_num: 100,
1312 value_denom: 1,
1313 reconcile_state: None,
1314 reconcile_date: None,
1315 lot_id: None,
1316 };
1317 let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
1318 let now = Utc::now();
1319
1320 CreateTransaction::new()
1321 .user_id(user.id)
1322 .splits(splits)
1323 .id(tx_id)
1324 .post_date(now)
1325 .enter_date(now)
1326 .note("Original note".to_string())
1327 .run()
1328 .await?;
1329
1330 let new_note = "Updated note".to_string();
1332 if let Some(CmdResult::Entity(FinanceEntity::Transaction(updated_tx))) =
1333 UpdateTransaction::new()
1334 .user_id(user.id)
1335 .transaction_id(tx_id)
1336 .note(new_note.clone())
1337 .run()
1338 .await?
1339 {
1340 assert_eq!(updated_tx.id, tx_id);
1341 } else {
1342 panic!("Expected Transaction entity result");
1343 }
1344
1345 let new_split1 = Split {
1347 id: Uuid::new_v4(),
1348 tx_id,
1349 account_id: account1.id,
1350 commodity_id,
1351 value_num: -200,
1352 value_denom: 1,
1353 reconcile_state: None,
1354 reconcile_date: None,
1355 lot_id: None,
1356 };
1357 let new_split2 = Split {
1358 id: Uuid::new_v4(),
1359 tx_id,
1360 account_id: account2.id,
1361 commodity_id,
1362 value_num: 200,
1363 value_denom: 1,
1364 reconcile_state: None,
1365 reconcile_date: None,
1366 lot_id: None,
1367 };
1368 let new_splits = vec![
1369 FinanceEntity::Split(new_split1),
1370 FinanceEntity::Split(new_split2),
1371 ];
1372
1373 if let Some(CmdResult::Entity(FinanceEntity::Transaction(updated_tx))) =
1374 UpdateTransaction::new()
1375 .user_id(user.id)
1376 .transaction_id(tx_id)
1377 .splits(new_splits)
1378 .run()
1379 .await?
1380 {
1381 assert_eq!(updated_tx.id, tx_id);
1382 } else {
1383 panic!("Expected Transaction entity result");
1384 }
1385
1386 let unbalanced_split1 = Split {
1388 id: Uuid::new_v4(),
1389 tx_id,
1390 account_id: account1.id,
1391 commodity_id,
1392 value_num: -100, value_denom: 1,
1394 reconcile_state: None,
1395 reconcile_date: None,
1396 lot_id: None,
1397 };
1398 let unbalanced_split2 = Split {
1399 id: Uuid::new_v4(),
1400 tx_id,
1401 account_id: account2.id,
1402 commodity_id,
1403 value_num: 50, value_denom: 1,
1405 reconcile_state: None,
1406 reconcile_date: None,
1407 lot_id: None,
1408 };
1409 let unbalanced_splits = vec![
1410 FinanceEntity::Split(unbalanced_split1),
1411 FinanceEntity::Split(unbalanced_split2),
1412 ];
1413
1414 let result = UpdateTransaction::new()
1415 .user_id(user.id)
1416 .transaction_id(tx_id)
1417 .splits(unbalanced_splits)
1418 .run()
1419 .await;
1420 assert!(result.is_err(), "Expected error for unbalanced splits");
1421
1422 if let Some(CmdResult::TaggedTransactions { entities, .. }) = GetTransaction::new()
1424 .user_id(user.id)
1425 .transaction_id(tx_id)
1426 .run()
1427 .await?
1428 {
1429 assert_eq!(entities.len(), 1, "Expected one transaction");
1430 } else {
1432 panic!("Expected transaction to still exist after failed update");
1433 }
1434
1435 let result = UpdateTransaction::new()
1437 .user_id(user.id)
1438 .transaction_id(Uuid::new_v4())
1439 .note("Should fail".to_string())
1440 .run()
1441 .await;
1442 assert!(
1443 result.is_err(),
1444 "Expected error for non-existent transaction"
1445 );
1446 }
1447
1448 #[local_db_sqlx_test]
1449 async fn test_update_transaction_atomicity(pool: PgPool) -> anyhow::Result<()> {
1450 let user = USER.get().unwrap();
1451 user.commit()
1452 .await
1453 .expect("Failed to commit user to database");
1454
1455 let commodity_result = CreateCommodity::new()
1457 .symbol("TST".to_string())
1458 .name("Test Commodity".to_string())
1459 .user_id(user.id)
1460 .run()
1461 .await?;
1462
1463 let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
1464 uuid::Uuid::parse_str(&id)?
1465 } else {
1466 panic!("Expected commodity ID string result");
1467 };
1468
1469 let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
1471 CreateAccount::new()
1472 .name("Account 1".to_string())
1473 .user_id(user.id)
1474 .run()
1475 .await?
1476 {
1477 account
1478 } else {
1479 panic!("Expected account entity result");
1480 };
1481
1482 let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
1483 CreateAccount::new()
1484 .name("Account 2".to_string())
1485 .user_id(user.id)
1486 .run()
1487 .await?
1488 {
1489 account
1490 } else {
1491 panic!("Expected account entity result");
1492 };
1493
1494 let tx_id = Uuid::new_v4();
1496 let split1 = Split {
1497 id: Uuid::new_v4(),
1498 tx_id,
1499 account_id: account1.id,
1500 commodity_id,
1501 value_num: -100,
1502 value_denom: 1,
1503 reconcile_state: None,
1504 reconcile_date: None,
1505 lot_id: None,
1506 };
1507 let split2 = Split {
1508 id: Uuid::new_v4(),
1509 tx_id,
1510 account_id: account2.id,
1511 commodity_id,
1512 value_num: 100,
1513 value_denom: 1,
1514 reconcile_state: None,
1515 reconcile_date: None,
1516 lot_id: None,
1517 };
1518 let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
1519 let now = Utc::now();
1520
1521 CreateTransaction::new()
1522 .user_id(user.id)
1523 .splits(splits)
1524 .id(tx_id)
1525 .post_date(now)
1526 .enter_date(now)
1527 .note("Original transaction".to_string())
1528 .run()
1529 .await?;
1530
1531 let wrong_tx_id = Uuid::new_v4();
1533 let invalid_split = Split {
1534 id: Uuid::new_v4(),
1535 tx_id: wrong_tx_id, account_id: account1.id,
1537 commodity_id,
1538 value_num: -50,
1539 value_denom: 1,
1540 reconcile_state: None,
1541 reconcile_date: None,
1542 lot_id: None,
1543 };
1544 let valid_split = Split {
1545 id: Uuid::new_v4(),
1546 tx_id,
1547 account_id: account2.id,
1548 commodity_id,
1549 value_num: 50,
1550 value_denom: 1,
1551 reconcile_state: None,
1552 reconcile_date: None,
1553 lot_id: None,
1554 };
1555 let mismatched_splits = vec![
1556 FinanceEntity::Split(invalid_split),
1557 FinanceEntity::Split(valid_split),
1558 ];
1559
1560 let result = UpdateTransaction::new()
1561 .user_id(user.id)
1562 .transaction_id(tx_id)
1563 .splits(mismatched_splits)
1564 .run()
1565 .await;
1566
1567 assert!(
1568 result.is_err(),
1569 "Expected error for split transaction ID mismatch"
1570 );
1571 if let Err(CmdError::Args(msg)) = result {
1572 assert!(msg.contains("Split transaction ID mismatch"));
1573 } else {
1574 panic!("Expected CmdError::Args with transaction ID mismatch message");
1575 }
1576
1577 if let Some(CmdResult::TaggedTransactions { entities, .. }) = GetTransaction::new()
1579 .user_id(user.id)
1580 .transaction_id(tx_id)
1581 .run()
1582 .await?
1583 {
1584 assert_eq!(entities.len(), 1, "Expected one transaction");
1585 } else {
1586 panic!("Expected transaction to still exist after failed update");
1587 }
1588
1589 let invalid_splits = vec![
1591 FinanceEntity::Account(Account {
1592 id: account1.id,
1593 parent: account1.parent,
1594 }), ];
1596
1597 let result = UpdateTransaction::new()
1598 .user_id(user.id)
1599 .transaction_id(tx_id)
1600 .splits(invalid_splits)
1601 .run()
1602 .await;
1603
1604 assert!(
1605 result.is_err(),
1606 "Expected error for invalid entity type in splits"
1607 );
1608 if let Err(CmdError::Args(msg)) = result {
1609 assert!(msg.contains("Invalid entity type in splits"));
1610 } else {
1611 panic!("Expected CmdError::Args with invalid entity type message");
1612 }
1613
1614 let invalid_prices = vec![
1616 FinanceEntity::Account(Account {
1617 id: account1.id,
1618 parent: account1.parent,
1619 }), ];
1621
1622 let result = UpdateTransaction::new()
1623 .user_id(user.id)
1624 .transaction_id(tx_id)
1625 .prices(invalid_prices)
1626 .run()
1627 .await;
1628
1629 assert!(
1630 result.is_err(),
1631 "Expected error for invalid entity type in prices"
1632 );
1633 if let Err(CmdError::Args(msg)) = result {
1634 assert!(msg.contains("Invalid entity type in prices"));
1635 } else {
1636 panic!("Expected CmdError::Args with invalid entity type message");
1637 }
1638
1639 let mut invalid_tags = HashMap::new();
1641 invalid_tags.insert(
1642 "test".to_string(),
1643 FinanceEntity::Account(Account {
1644 id: account1.id,
1645 parent: account1.parent,
1646 }),
1647 );
1648
1649 let result = UpdateTransaction::new()
1650 .user_id(user.id)
1651 .transaction_id(tx_id)
1652 .tags(invalid_tags)
1653 .run()
1654 .await;
1655
1656 assert!(
1657 result.is_err(),
1658 "Expected error for invalid entity type in tags"
1659 );
1660 if let Err(CmdError::Args(msg)) = result {
1661 assert!(msg.contains("Invalid entity type in tags"));
1662 } else {
1663 panic!("Expected CmdError::Args with invalid entity type message");
1664 }
1665
1666 let mut conn = user.get_connection().await?;
1668 let initial_split_count = sqlx::query!(
1669 "SELECT COUNT(*) as count FROM splits WHERE tx_id = $1",
1670 tx_id
1671 )
1672 .fetch_one(&mut *conn)
1673 .await?
1674 .count
1675 .unwrap_or(0);
1676
1677 let invalid_account_split = Split {
1679 id: Uuid::new_v4(),
1680 tx_id,
1681 account_id: Uuid::new_v4(), commodity_id,
1683 value_num: -100,
1684 value_denom: 1,
1685 reconcile_state: None,
1686 reconcile_date: None,
1687 lot_id: None,
1688 };
1689 let balancing_split = Split {
1690 id: Uuid::new_v4(),
1691 tx_id,
1692 account_id: account2.id,
1693 commodity_id,
1694 value_num: 100,
1695 value_denom: 1,
1696 reconcile_state: None,
1697 reconcile_date: None,
1698 lot_id: None,
1699 };
1700 let failing_splits = vec![
1701 FinanceEntity::Split(invalid_account_split),
1702 FinanceEntity::Split(balancing_split),
1703 ];
1704
1705 let result = UpdateTransaction::new()
1706 .user_id(user.id)
1707 .transaction_id(tx_id)
1708 .splits(failing_splits)
1709 .run()
1710 .await;
1711
1712 assert!(result.is_err(), "Expected error for non-existent account");
1713
1714 let final_split_count = sqlx::query!(
1716 "SELECT COUNT(*) as count FROM splits WHERE tx_id = $1",
1717 tx_id
1718 )
1719 .fetch_one(&mut *conn)
1720 .await?
1721 .count
1722 .unwrap_or(0);
1723
1724 assert_eq!(
1725 initial_split_count, final_split_count,
1726 "Split count should be unchanged after failed update due to rollback"
1727 );
1728
1729 if let Some(CmdResult::TaggedTransactions { entities, .. }) = GetTransaction::new()
1731 .user_id(user.id)
1732 .transaction_id(tx_id)
1733 .run()
1734 .await?
1735 {
1736 assert_eq!(entities.len(), 1, "Expected one transaction");
1737 } else {
1738 panic!("Expected transaction to still exist after failed database operation");
1739 }
1740 }
1741
1742 #[local_db_sqlx_test]
1743 async fn test_update_transaction_prices_and_tags(pool: PgPool) -> anyhow::Result<()> {
1744 let user = USER.get().unwrap();
1745 user.commit()
1746 .await
1747 .expect("Failed to commit user to database");
1748
1749 let commodity_result = CreateCommodity::new()
1751 .symbol("TST".to_string())
1752 .name("Test Commodity".to_string())
1753 .user_id(user.id)
1754 .run()
1755 .await?;
1756
1757 let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
1758 uuid::Uuid::parse_str(&id)?
1759 } else {
1760 panic!("Expected commodity ID string result");
1761 };
1762
1763 let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
1765 CreateAccount::new()
1766 .name("Account 1".to_string())
1767 .user_id(user.id)
1768 .run()
1769 .await?
1770 {
1771 account
1772 } else {
1773 panic!("Expected account entity result");
1774 };
1775
1776 let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
1777 CreateAccount::new()
1778 .name("Account 2".to_string())
1779 .user_id(user.id)
1780 .run()
1781 .await?
1782 {
1783 account
1784 } else {
1785 panic!("Expected account entity result");
1786 };
1787
1788 let tag1_id = Uuid::new_v4();
1790 let tag2_id = Uuid::new_v4();
1791 let mut conn = user.get_connection().await?;
1792
1793 sqlx::query!(
1794 "INSERT INTO tags (id, tag_name, tag_value, description) VALUES ($1, $2, $3, $4)",
1795 tag1_id,
1796 "category",
1797 "expense",
1798 Some("Expense category".to_string())
1799 )
1800 .execute(&mut *conn)
1801 .await?;
1802
1803 sqlx::query!(
1804 "INSERT INTO tags (id, tag_name, tag_value, description) VALUES ($1, $2, $3, $4)",
1805 tag2_id,
1806 "project",
1807 "finance_app",
1808 Some("Finance app project".to_string())
1809 )
1810 .execute(&mut *conn)
1811 .await?;
1812
1813 let tx_id = Uuid::new_v4();
1815 let split1 = Split {
1816 id: Uuid::new_v4(),
1817 tx_id,
1818 account_id: account1.id,
1819 commodity_id,
1820 value_num: -100,
1821 value_denom: 1,
1822 reconcile_state: None,
1823 reconcile_date: None,
1824 lot_id: None,
1825 };
1826 let split2 = Split {
1827 id: Uuid::new_v4(),
1828 tx_id,
1829 account_id: account2.id,
1830 commodity_id,
1831 value_num: 100,
1832 value_denom: 1,
1833 reconcile_state: None,
1834 reconcile_date: None,
1835 lot_id: None,
1836 };
1837 let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
1838 let now = Utc::now();
1839
1840 CreateTransaction::new()
1841 .user_id(user.id)
1842 .splits(splits)
1843 .id(tx_id)
1844 .post_date(now)
1845 .enter_date(now)
1846 .note("Initial transaction".to_string())
1847 .run()
1848 .await?;
1849
1850 let price1 = Price {
1852 id: Uuid::new_v4(),
1853 commodity_id,
1854 currency_id: commodity_id, commodity_split: None, currency_split: None,
1857 date: now,
1858 value_num: 100,
1859 value_denom: 100,
1860 };
1861
1862 let prices = vec![FinanceEntity::Price(price1)];
1863
1864 if let Some(CmdResult::Entity(FinanceEntity::Transaction(updated_tx))) =
1865 UpdateTransaction::new()
1866 .user_id(user.id)
1867 .transaction_id(tx_id)
1868 .prices(prices)
1869 .run()
1870 .await?
1871 {
1872 assert_eq!(updated_tx.id, tx_id);
1873 } else {
1874 panic!("Expected Transaction entity result for price update");
1875 }
1876
1877 let price_count = sqlx::query!(
1879 "SELECT COUNT(*) as count FROM prices WHERE commodity_id = $1 AND currency_id = $2",
1880 commodity_id,
1881 commodity_id
1882 )
1883 .fetch_one(&mut *conn)
1884 .await?
1885 .count
1886 .unwrap_or(0);
1887 assert_eq!(price_count, 1, "Expected one price record");
1888
1889 let mut tags = HashMap::new();
1891 tags.insert(
1892 "category".to_string(),
1893 FinanceEntity::Tag(Tag {
1894 id: tag1_id,
1895 tag_name: "category".to_string(),
1896 tag_value: "expense".to_string(),
1897 description: Some("Expense category".to_string()),
1898 }),
1899 );
1900 tags.insert(
1901 "project".to_string(),
1902 FinanceEntity::Tag(Tag {
1903 id: tag2_id,
1904 tag_name: "project".to_string(),
1905 tag_value: "finance_app".to_string(),
1906 description: Some("Finance app project".to_string()),
1907 }),
1908 );
1909
1910 if let Some(CmdResult::Entity(FinanceEntity::Transaction(updated_tx))) =
1911 UpdateTransaction::new()
1912 .user_id(user.id)
1913 .transaction_id(tx_id)
1914 .tags(tags)
1915 .run()
1916 .await?
1917 {
1918 assert_eq!(updated_tx.id, tx_id);
1919 } else {
1920 panic!("Expected Transaction entity result for tag update");
1921 }
1922
1923 let tag_count = sqlx::query!(
1925 "SELECT COUNT(*) as count FROM transaction_tags WHERE tx_id = $1",
1926 tx_id
1927 )
1928 .fetch_one(&mut *conn)
1929 .await?
1930 .count
1931 .unwrap_or(0);
1932 assert_eq!(tag_count, 2, "Expected two tag records");
1933
1934 let new_split1 = Split {
1936 id: Uuid::new_v4(),
1937 tx_id,
1938 account_id: account1.id,
1939 commodity_id,
1940 value_num: -200,
1941 value_denom: 1,
1942 reconcile_state: None,
1943 reconcile_date: None,
1944 lot_id: None,
1945 };
1946 let new_split2 = Split {
1947 id: Uuid::new_v4(),
1948 tx_id,
1949 account_id: account2.id,
1950 commodity_id,
1951 value_num: 200,
1952 value_denom: 1,
1953 reconcile_state: None,
1954 reconcile_date: None,
1955 lot_id: None,
1956 };
1957 let new_splits = vec![
1958 FinanceEntity::Split(new_split1),
1959 FinanceEntity::Split(new_split2),
1960 ];
1961
1962 let new_price = Price {
1963 id: Uuid::new_v4(),
1964 commodity_id,
1965 currency_id: commodity_id,
1966 commodity_split: None,
1967 currency_split: None,
1968 date: now,
1969 value_num: 110,
1970 value_denom: 100,
1971 };
1972 let new_prices = vec![FinanceEntity::Price(new_price)];
1973
1974 let mut new_tags = HashMap::new();
1975 new_tags.insert(
1976 "category".to_string(),
1977 FinanceEntity::Tag(Tag {
1978 id: tag1_id,
1979 tag_name: "category".to_string(),
1980 tag_value: "income".to_string(), description: Some("Expense category".to_string()),
1982 }),
1983 );
1984
1985 if let Some(CmdResult::Entity(FinanceEntity::Transaction(updated_tx))) =
1986 UpdateTransaction::new()
1987 .user_id(user.id)
1988 .transaction_id(tx_id)
1989 .splits(new_splits)
1990 .prices(new_prices)
1991 .tags(new_tags)
1992 .run()
1993 .await?
1994 {
1995 assert_eq!(updated_tx.id, tx_id);
1996 } else {
1997 panic!("Expected Transaction entity result for combined update");
1998 }
1999
2000 let final_split_count = sqlx::query!(
2002 "SELECT COUNT(*) as count FROM splits WHERE tx_id = $1",
2003 tx_id
2004 )
2005 .fetch_one(&mut *conn)
2006 .await?
2007 .count
2008 .unwrap_or(0);
2009 assert_eq!(final_split_count, 2, "Expected two splits after update");
2010
2011 let final_price_count = sqlx::query!(
2012 "SELECT COUNT(*) as count FROM prices WHERE commodity_id = $1",
2013 commodity_id
2014 )
2015 .fetch_one(&mut *conn)
2016 .await?
2017 .count
2018 .unwrap_or(0);
2019 assert_eq!(final_price_count, 2, "Expected two prices after update");
2020
2021 let final_tag_count = sqlx::query!(
2022 "SELECT COUNT(*) as count FROM transaction_tags WHERE tx_id = $1",
2023 tx_id
2024 )
2025 .fetch_one(&mut *conn)
2026 .await?
2027 .count
2028 .unwrap_or(0);
2029 assert_eq!(final_tag_count, 1, "Expected one tag after update");
2030
2031 let mut invalid_tags = HashMap::new();
2033 invalid_tags.insert(
2034 "invalid".to_string(),
2035 FinanceEntity::Tag(Tag {
2036 id: Uuid::new_v4(), tag_name: "invalid".to_string(),
2038 tag_value: "value".to_string(),
2039 description: Some("Invalid tag".to_string()),
2040 }),
2041 );
2042
2043 let result = UpdateTransaction::new()
2044 .user_id(user.id)
2045 .transaction_id(tx_id)
2046 .tags(invalid_tags)
2047 .run()
2048 .await;
2049
2050 assert!(
2051 result.is_err(),
2052 "Expected error for invalid tag with non-existent tag ID"
2053 );
2054
2055 let unchanged_tag_count = sqlx::query!(
2057 "SELECT COUNT(*) as count FROM transaction_tags WHERE tx_id = $1",
2058 tx_id
2059 )
2060 .fetch_one(&mut *conn)
2061 .await?
2062 .count
2063 .unwrap_or(0);
2064 assert_eq!(
2065 unchanged_tag_count, 1,
2066 "Tag count should be unchanged after failed update"
2067 );
2068 }
2069
2070 #[local_db_sqlx_test]
2071 async fn test_delete_transaction_simple(pool: PgPool) -> anyhow::Result<()> {
2072 let user = USER.get().unwrap();
2073 user.commit()
2074 .await
2075 .expect("Failed to commit user to database");
2076
2077 let commodity_result = CreateCommodity::new()
2078 .symbol("TST".to_string())
2079 .name("Test Commodity".to_string())
2080 .user_id(user.id)
2081 .run()
2082 .await?;
2083
2084 let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
2085 uuid::Uuid::parse_str(&id)?
2086 } else {
2087 panic!("Expected commodity ID string result");
2088 };
2089
2090 let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
2091 CreateAccount::new()
2092 .name("Account 1".to_string())
2093 .user_id(user.id)
2094 .run()
2095 .await?
2096 {
2097 account
2098 } else {
2099 panic!("Expected account entity result");
2100 };
2101
2102 let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
2103 CreateAccount::new()
2104 .name("Account 2".to_string())
2105 .user_id(user.id)
2106 .run()
2107 .await?
2108 {
2109 account
2110 } else {
2111 panic!("Expected account entity result");
2112 };
2113
2114 let tx_id = Uuid::new_v4();
2115 let split1 = Split {
2116 id: Uuid::new_v4(),
2117 tx_id,
2118 account_id: account1.id,
2119 commodity_id,
2120 value_num: -100,
2121 value_denom: 1,
2122 reconcile_state: None,
2123 reconcile_date: None,
2124 lot_id: None,
2125 };
2126 let split2 = Split {
2127 id: Uuid::new_v4(),
2128 tx_id,
2129 account_id: account2.id,
2130 commodity_id,
2131 value_num: 100,
2132 value_denom: 1,
2133 reconcile_state: None,
2134 reconcile_date: None,
2135 lot_id: None,
2136 };
2137 let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
2138 let now = Utc::now();
2139
2140 CreateTransaction::new()
2141 .user_id(user.id)
2142 .splits(splits)
2143 .id(tx_id)
2144 .post_date(now)
2145 .enter_date(now)
2146 .run()
2147 .await?;
2148
2149 let result = DeleteTransaction::new()
2150 .user_id(user.id)
2151 .transaction_id(tx_id)
2152 .run()
2153 .await?;
2154
2155 assert!(result.is_some(), "Expected successful deletion");
2156
2157 let mut conn = user.get_connection().await?;
2158 let tx_exists = sqlx::query!(
2159 "SELECT COUNT(*) as count FROM transactions WHERE id = $1",
2160 tx_id
2161 )
2162 .fetch_one(&mut *conn)
2163 .await?
2164 .count
2165 .unwrap_or(0);
2166 assert_eq!(tx_exists, 0, "Transaction should be deleted");
2167
2168 let splits_exist = sqlx::query!(
2169 "SELECT COUNT(*) as count FROM splits WHERE tx_id = $1",
2170 tx_id
2171 )
2172 .fetch_one(&mut *conn)
2173 .await?
2174 .count
2175 .unwrap_or(0);
2176 assert_eq!(splits_exist, 0, "Splits should be deleted");
2177 }
2178
2179 #[local_db_sqlx_test]
2180 async fn test_delete_transaction_with_tags_and_prices(pool: PgPool) -> anyhow::Result<()> {
2181 let user = USER.get().unwrap();
2182 user.commit()
2183 .await
2184 .expect("Failed to commit user to database");
2185
2186 let commodity_result = CreateCommodity::new()
2187 .symbol("TST".to_string())
2188 .name("Test Commodity".to_string())
2189 .user_id(user.id)
2190 .run()
2191 .await?;
2192
2193 let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
2194 uuid::Uuid::parse_str(&id)?
2195 } else {
2196 panic!("Expected commodity ID string result");
2197 };
2198
2199 let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
2200 CreateAccount::new()
2201 .name("Account 1".to_string())
2202 .user_id(user.id)
2203 .run()
2204 .await?
2205 {
2206 account
2207 } else {
2208 panic!("Expected account entity result");
2209 };
2210
2211 let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
2212 CreateAccount::new()
2213 .name("Account 2".to_string())
2214 .user_id(user.id)
2215 .run()
2216 .await?
2217 {
2218 account
2219 } else {
2220 panic!("Expected account entity result");
2221 };
2222
2223 let tx_id = Uuid::new_v4();
2224 let split1_id = Uuid::new_v4();
2225 let split2_id = Uuid::new_v4();
2226 let split1 = Split {
2227 id: split1_id,
2228 tx_id,
2229 account_id: account1.id,
2230 commodity_id,
2231 value_num: -100,
2232 value_denom: 1,
2233 reconcile_state: None,
2234 reconcile_date: None,
2235 lot_id: None,
2236 };
2237 let split2 = Split {
2238 id: split2_id,
2239 tx_id,
2240 account_id: account2.id,
2241 commodity_id,
2242 value_num: 100,
2243 value_denom: 1,
2244 reconcile_state: None,
2245 reconcile_date: None,
2246 lot_id: None,
2247 };
2248 let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
2249 let now = Utc::now();
2250
2251 let price = Price {
2252 id: Uuid::new_v4(),
2253 commodity_id,
2254 currency_id: commodity_id,
2255 commodity_split: Some(split1_id),
2256 currency_split: Some(split2_id),
2257 date: now,
2258 value_num: 100,
2259 value_denom: 100,
2260 };
2261
2262 CreateTransaction::new()
2263 .user_id(user.id)
2264 .splits(splits)
2265 .id(tx_id)
2266 .post_date(now)
2267 .enter_date(now)
2268 .prices(vec![FinanceEntity::Price(price)])
2269 .note("Test note".to_string())
2270 .run()
2271 .await?;
2272
2273 let mut conn = user.get_connection().await?;
2274
2275 let tag_count_before = sqlx::query!("SELECT COUNT(*) as count FROM tags")
2276 .fetch_one(&mut *conn)
2277 .await?
2278 .count
2279 .unwrap_or(0);
2280 assert!(tag_count_before > 0, "Should have tags before deletion");
2281
2282 let price_count_before = sqlx::query!(
2283 "SELECT COUNT(*) as count FROM prices WHERE commodity_split_id = $1 OR currency_split_id = $2",
2284 split1_id,
2285 split2_id
2286 )
2287 .fetch_one(&mut *conn)
2288 .await?
2289 .count
2290 .unwrap_or(0);
2291 assert_eq!(
2292 price_count_before, 1,
2293 "Should have one price before deletion"
2294 );
2295
2296 let result = DeleteTransaction::new()
2297 .user_id(user.id)
2298 .transaction_id(tx_id)
2299 .run()
2300 .await?;
2301
2302 assert!(result.is_some(), "Expected successful deletion");
2303
2304 let tx_exists = sqlx::query!(
2305 "SELECT COUNT(*) as count FROM transactions WHERE id = $1",
2306 tx_id
2307 )
2308 .fetch_one(&mut *conn)
2309 .await?
2310 .count
2311 .unwrap_or(0);
2312 assert_eq!(tx_exists, 0, "Transaction should be deleted");
2313
2314 let splits_exist = sqlx::query!(
2315 "SELECT COUNT(*) as count FROM splits WHERE tx_id = $1",
2316 tx_id
2317 )
2318 .fetch_one(&mut *conn)
2319 .await?
2320 .count
2321 .unwrap_or(0);
2322 assert_eq!(splits_exist, 0, "Splits should be deleted");
2323
2324 let tx_tags_exist = sqlx::query!(
2325 "SELECT COUNT(*) as count FROM transaction_tags WHERE tx_id = $1",
2326 tx_id
2327 )
2328 .fetch_one(&mut *conn)
2329 .await?
2330 .count
2331 .unwrap_or(0);
2332 assert_eq!(
2333 tx_tags_exist, 0,
2334 "Transaction tags associations should be deleted"
2335 );
2336
2337 let prices_exist = sqlx::query!(
2338 "SELECT COUNT(*) as count FROM prices WHERE commodity_split_id = $1 OR currency_split_id = $2",
2339 split1_id,
2340 split2_id
2341 )
2342 .fetch_one(&mut *conn)
2343 .await?
2344 .count
2345 .unwrap_or(0);
2346 assert_eq!(prices_exist, 0, "Prices should be deleted");
2347 }
2348
2349 #[local_db_sqlx_test]
2350 async fn test_delete_transaction_nonexistent(pool: PgPool) -> anyhow::Result<()> {
2351 let user = USER.get().unwrap();
2352 user.commit()
2353 .await
2354 .expect("Failed to commit user to database");
2355
2356 let nonexistent_id = Uuid::new_v4();
2357 let result = DeleteTransaction::new()
2358 .user_id(user.id)
2359 .transaction_id(nonexistent_id)
2360 .run()
2361 .await;
2362
2363 assert!(
2364 result.is_err(),
2365 "Expected error for non-existent transaction"
2366 );
2367 if let Err(CmdError::Args(msg)) = result {
2368 assert!(msg.contains("Transaction not found"));
2369 } else {
2370 panic!("Expected CmdError::Args with 'Transaction not found' message");
2371 }
2372 }
2373
2374 #[local_db_sqlx_test]
2375 async fn test_delete_transaction_orphaned_tags(pool: PgPool) -> anyhow::Result<()> {
2376 let user = USER.get().unwrap();
2377 user.commit()
2378 .await
2379 .expect("Failed to commit user to database");
2380
2381 let commodity_result = CreateCommodity::new()
2382 .symbol("TST".to_string())
2383 .name("Test Commodity".to_string())
2384 .user_id(user.id)
2385 .run()
2386 .await?;
2387
2388 let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
2389 uuid::Uuid::parse_str(&id)?
2390 } else {
2391 panic!("Expected commodity ID string result");
2392 };
2393
2394 let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
2395 CreateAccount::new()
2396 .name("Account 1".to_string())
2397 .user_id(user.id)
2398 .run()
2399 .await?
2400 {
2401 account
2402 } else {
2403 panic!("Expected account entity result");
2404 };
2405
2406 let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
2407 CreateAccount::new()
2408 .name("Account 2".to_string())
2409 .user_id(user.id)
2410 .run()
2411 .await?
2412 {
2413 account
2414 } else {
2415 panic!("Expected account entity result");
2416 };
2417
2418 let tx_id = Uuid::new_v4();
2419 let split1 = Split {
2420 id: Uuid::new_v4(),
2421 tx_id,
2422 account_id: account1.id,
2423 commodity_id,
2424 value_num: -100,
2425 value_denom: 1,
2426 reconcile_state: None,
2427 reconcile_date: None,
2428 lot_id: None,
2429 };
2430 let split2 = Split {
2431 id: Uuid::new_v4(),
2432 tx_id,
2433 account_id: account2.id,
2434 commodity_id,
2435 value_num: 100,
2436 value_denom: 1,
2437 reconcile_state: None,
2438 reconcile_date: None,
2439 lot_id: None,
2440 };
2441 let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
2442 let now = Utc::now();
2443
2444 CreateTransaction::new()
2445 .user_id(user.id)
2446 .splits(splits)
2447 .id(tx_id)
2448 .post_date(now)
2449 .enter_date(now)
2450 .note("Orphaned tag test".to_string())
2451 .run()
2452 .await?;
2453
2454 let mut conn = user.get_connection().await?;
2455
2456 let tag_id = sqlx::query!(
2457 "SELECT tag_id FROM transaction_tags WHERE tx_id = $1",
2458 tx_id
2459 )
2460 .fetch_one(&mut *conn)
2461 .await?
2462 .tag_id;
2463
2464 DeleteTransaction::new()
2465 .user_id(user.id)
2466 .transaction_id(tx_id)
2467 .run()
2468 .await?;
2469
2470 let orphaned_tag_exists =
2471 sqlx::query!("SELECT COUNT(*) as count FROM tags WHERE id = $1", tag_id)
2472 .fetch_one(&mut *conn)
2473 .await?
2474 .count
2475 .unwrap_or(0);
2476 assert_eq!(orphaned_tag_exists, 0, "Orphaned tag should be deleted");
2477 }
2478
2479 const GROCERIES_SCRIPT_WASM: &[u8] =
2480 include_bytes!("../../../web/static/wasm/groceries_markup.wasm");
2481
2482 const TAG_SYNC_SCRIPT_WASM: &[u8] = include_bytes!("../../../web/static/wasm/tag_sync.wasm");
2483
2484 #[local_db_sqlx_test]
2485 async fn test_create_transaction_with_all_scripts_completes(
2486 pool: PgPool,
2487 ) -> anyhow::Result<()> {
2488 let user = USER.get().unwrap();
2489 user.commit()
2490 .await
2491 .expect("Failed to commit user to database");
2492
2493 let mut conn = user.get_connection().await?;
2494
2495 let groceries_script_id = user
2496 .create_script(GROCERIES_SCRIPT_WASM.to_vec(), None)
2497 .await?;
2498 let tag_sync_script_id = user
2499 .create_script(TAG_SYNC_SCRIPT_WASM.to_vec(), None)
2500 .await?;
2501
2502 let commodity_result = CreateCommodity::new()
2503 .symbol("TST".to_string())
2504 .name("Test Commodity".to_string())
2505 .user_id(user.id)
2506 .run()
2507 .await?;
2508
2509 let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
2510 uuid::Uuid::parse_str(&id)?
2511 } else {
2512 panic!("Expected commodity ID string result");
2513 };
2514
2515 let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
2516 CreateAccount::new()
2517 .name("Account 1".to_string())
2518 .user_id(user.id)
2519 .run()
2520 .await?
2521 {
2522 account
2523 } else {
2524 panic!("Expected account entity result");
2525 };
2526
2527 let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
2528 CreateAccount::new()
2529 .name("Account 2".to_string())
2530 .user_id(user.id)
2531 .run()
2532 .await?
2533 {
2534 account
2535 } else {
2536 panic!("Expected account entity result");
2537 };
2538
2539 let tx_id = Uuid::new_v4();
2540 let split1_id = Uuid::new_v4();
2541 let split2_id = Uuid::new_v4();
2542
2543 let split1 = Split {
2544 id: split1_id,
2545 tx_id,
2546 account_id: account1.id,
2547 commodity_id,
2548 value_num: -5000,
2549 value_denom: 100,
2550 reconcile_state: None,
2551 reconcile_date: None,
2552 lot_id: None,
2553 };
2554
2555 let split2 = Split {
2556 id: split2_id,
2557 tx_id,
2558 account_id: account2.id,
2559 commodity_id,
2560 value_num: 5000,
2561 value_denom: 100,
2562 reconcile_state: None,
2563 reconcile_date: None,
2564 lot_id: None,
2565 };
2566
2567 let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
2568 let now = Utc::now();
2569
2570 let result = tokio::time::timeout(
2576 std::time::Duration::from_secs(120),
2577 CreateTransaction::new()
2578 .user_id(user.id)
2579 .splits(splits)
2580 .id(tx_id)
2581 .post_date(now)
2582 .enter_date(now)
2583 .note("groceries".to_string())
2584 .run(),
2585 )
2586 .await;
2587
2588 assert!(
2589 result.is_ok(),
2590 "Transaction creation with scripts hung (>120s) — a script or lock is stuck"
2591 );
2592 result.unwrap()?;
2593
2594 let split1_tags = sqlx::query_file!("sql/select/tags/by_split.sql", split1_id)
2595 .fetch_all(&mut *conn)
2596 .await?;
2597
2598 let split1_has_category = split1_tags
2599 .iter()
2600 .any(|t| t.tag_name == "category" && t.tag_value == "groceries");
2601 assert!(
2602 split1_has_category,
2603 "Split 1 should have category=groceries tag from groceries script"
2604 );
2605
2606 user.delete_script(groceries_script_id).await?;
2607 user.delete_script(tag_sync_script_id).await?;
2608 }
2609
2610 #[local_db_sqlx_test]
2611 async fn test_create_transaction_with_script(pool: PgPool) -> anyhow::Result<()> {
2612 let user = USER.get().unwrap();
2613 user.commit()
2614 .await
2615 .expect("Failed to commit user to database");
2616
2617 let mut conn = user.get_connection().await?;
2618
2619 let script_id = user
2621 .create_script(GROCERIES_SCRIPT_WASM.to_vec(), None)
2622 .await?;
2623
2624 let commodity_result = CreateCommodity::new()
2626 .symbol("TST".to_string())
2627 .name("Test Commodity".to_string())
2628 .user_id(user.id)
2629 .run()
2630 .await?;
2631
2632 let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
2633 uuid::Uuid::parse_str(&id)?
2634 } else {
2635 panic!("Expected commodity ID string result");
2636 };
2637
2638 let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
2639 CreateAccount::new()
2640 .name("Account 1".to_string())
2641 .user_id(user.id)
2642 .run()
2643 .await?
2644 {
2645 account
2646 } else {
2647 panic!("Expected account entity result");
2648 };
2649
2650 let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
2651 CreateAccount::new()
2652 .name("Account 2".to_string())
2653 .user_id(user.id)
2654 .run()
2655 .await?
2656 {
2657 account
2658 } else {
2659 panic!("Expected account entity result");
2660 };
2661
2662 let tx_id = Uuid::new_v4();
2663 let split1_id = Uuid::new_v4();
2664 let split2_id = Uuid::new_v4();
2665
2666 let split1 = Split {
2667 id: split1_id,
2668 tx_id,
2669 account_id: account1.id,
2670 commodity_id,
2671 value_num: -5000,
2672 value_denom: 100,
2673 reconcile_state: None,
2674 reconcile_date: None,
2675 lot_id: None,
2676 };
2677
2678 let split2 = Split {
2679 id: split2_id,
2680 tx_id,
2681 account_id: account2.id,
2682 commodity_id,
2683 value_num: 5000,
2684 value_denom: 100,
2685 reconcile_state: None,
2686 reconcile_date: None,
2687 lot_id: None,
2688 };
2689
2690 let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
2691 let now = Utc::now();
2692
2693 CreateTransaction::new()
2695 .user_id(user.id)
2696 .splits(splits)
2697 .id(tx_id)
2698 .post_date(now)
2699 .enter_date(now)
2700 .note("groceries".to_string())
2701 .run()
2702 .await?;
2703
2704 let split1_tags = sqlx::query_file!("sql/select/tags/by_split.sql", split1_id)
2706 .fetch_all(&mut *conn)
2707 .await?;
2708
2709 let split2_tags = sqlx::query_file!("sql/select/tags/by_split.sql", split2_id)
2710 .fetch_all(&mut *conn)
2711 .await?;
2712
2713 let split1_has_category = split1_tags
2715 .iter()
2716 .any(|t| t.tag_name == "category" && t.tag_value == "groceries");
2717 assert!(
2718 split1_has_category,
2719 "Split 1 should have category=groceries tag from script. Tags: {:?}",
2720 split1_tags
2721 .iter()
2722 .map(|t| format!("{}={}", t.tag_name, t.tag_value))
2723 .collect::<Vec<_>>()
2724 );
2725
2726 let split2_has_category = split2_tags
2728 .iter()
2729 .any(|t| t.tag_name == "category" && t.tag_value == "groceries");
2730 assert!(
2731 split2_has_category,
2732 "Split 2 should have category=groceries tag from script. Tags: {:?}",
2733 split2_tags
2734 .iter()
2735 .map(|t| format!("{}={}", t.tag_name, t.tag_value))
2736 .collect::<Vec<_>>()
2737 );
2738
2739 user.delete_script(script_id).await?;
2741 }
2742
2743 #[local_db_sqlx_test]
2744 async fn test_create_transaction_script_skips_non_matching(pool: PgPool) -> anyhow::Result<()> {
2745 let user = USER.get().unwrap();
2746 user.commit()
2747 .await
2748 .expect("Failed to commit user to database");
2749
2750 let mut conn = user.get_connection().await?;
2751
2752 let script_id = user
2754 .create_script(GROCERIES_SCRIPT_WASM.to_vec(), None)
2755 .await?;
2756
2757 let commodity_result = CreateCommodity::new()
2759 .symbol("TST".to_string())
2760 .name("Test Commodity".to_string())
2761 .user_id(user.id)
2762 .run()
2763 .await?;
2764
2765 let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
2766 uuid::Uuid::parse_str(&id)?
2767 } else {
2768 panic!("Expected commodity ID string result");
2769 };
2770
2771 let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
2772 CreateAccount::new()
2773 .name("Account 1".to_string())
2774 .user_id(user.id)
2775 .run()
2776 .await?
2777 {
2778 account
2779 } else {
2780 panic!("Expected account entity result");
2781 };
2782
2783 let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
2784 CreateAccount::new()
2785 .name("Account 2".to_string())
2786 .user_id(user.id)
2787 .run()
2788 .await?
2789 {
2790 account
2791 } else {
2792 panic!("Expected account entity result");
2793 };
2794
2795 let tx_id = Uuid::new_v4();
2796 let split1_id = Uuid::new_v4();
2797 let split2_id = Uuid::new_v4();
2798
2799 let split1 = Split {
2800 id: split1_id,
2801 tx_id,
2802 account_id: account1.id,
2803 commodity_id,
2804 value_num: -5000,
2805 value_denom: 100,
2806 reconcile_state: None,
2807 reconcile_date: None,
2808 lot_id: None,
2809 };
2810
2811 let split2 = Split {
2812 id: split2_id,
2813 tx_id,
2814 account_id: account2.id,
2815 commodity_id,
2816 value_num: 5000,
2817 value_denom: 100,
2818 reconcile_state: None,
2819 reconcile_date: None,
2820 lot_id: None,
2821 };
2822
2823 let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
2824 let now = Utc::now();
2825
2826 CreateTransaction::new()
2828 .user_id(user.id)
2829 .splits(splits)
2830 .id(tx_id)
2831 .post_date(now)
2832 .enter_date(now)
2833 .note("other".to_string())
2834 .run()
2835 .await?;
2836
2837 let split1_tags = sqlx::query_file!("sql/select/tags/by_split.sql", split1_id)
2839 .fetch_all(&mut *conn)
2840 .await?;
2841
2842 let split2_tags = sqlx::query_file!("sql/select/tags/by_split.sql", split2_id)
2843 .fetch_all(&mut *conn)
2844 .await?;
2845
2846 let split1_has_category = split1_tags
2848 .iter()
2849 .any(|t| t.tag_name == "category" && t.tag_value == "groceries");
2850 assert!(
2851 !split1_has_category,
2852 "Split 1 should NOT have category tag for non-groceries transaction"
2853 );
2854
2855 let split2_has_category = split2_tags
2856 .iter()
2857 .any(|t| t.tag_name == "category" && t.tag_value == "groceries");
2858 assert!(
2859 !split2_has_category,
2860 "Split 2 should NOT have category tag for non-groceries transaction"
2861 );
2862
2863 user.delete_script(script_id).await?;
2865 }
2866
2867 async fn create_test_transaction(
2868 user: &User,
2869 account1_id: Uuid,
2870 account2_id: Uuid,
2871 commodity_id: Uuid,
2872 post_date: DateTime<Utc>,
2873 amount: i64,
2874 ) -> anyhow::Result<Uuid> {
2875 let tx_id = Uuid::new_v4();
2876 let split1 = Split {
2877 id: Uuid::new_v4(),
2878 tx_id,
2879 account_id: account1_id,
2880 commodity_id,
2881 value_num: -amount,
2882 value_denom: 1,
2883 reconcile_state: None,
2884 reconcile_date: None,
2885 lot_id: None,
2886 };
2887 let split2 = Split {
2888 id: Uuid::new_v4(),
2889 tx_id,
2890 account_id: account2_id,
2891 commodity_id,
2892 value_num: amount,
2893 value_denom: 1,
2894 reconcile_state: None,
2895 reconcile_date: None,
2896 lot_id: None,
2897 };
2898 let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
2899
2900 CreateTransaction::new()
2901 .user_id(user.id)
2902 .splits(splits)
2903 .id(tx_id)
2904 .post_date(post_date)
2905 .enter_date(Utc::now())
2906 .run()
2907 .await?;
2908
2909 Ok(tx_id)
2910 }
2911
2912 #[local_db_sqlx_test]
2913 async fn test_pagination_limit(pool: PgPool) -> anyhow::Result<()> {
2914 let user = USER.get().unwrap();
2915 user.commit()
2916 .await
2917 .expect("Failed to commit user to database");
2918
2919 let commodity_result = CreateCommodity::new()
2920 .symbol("TST".to_string())
2921 .name("Test Commodity".to_string())
2922 .user_id(user.id)
2923 .run()
2924 .await?;
2925
2926 let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
2927 uuid::Uuid::parse_str(&id)?
2928 } else {
2929 panic!("Expected commodity ID string result");
2930 };
2931
2932 let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
2933 CreateAccount::new()
2934 .name("Account 1".to_string())
2935 .user_id(user.id)
2936 .run()
2937 .await?
2938 {
2939 account
2940 } else {
2941 panic!("Expected account entity result");
2942 };
2943
2944 let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
2945 CreateAccount::new()
2946 .name("Account 2".to_string())
2947 .user_id(user.id)
2948 .run()
2949 .await?
2950 {
2951 account
2952 } else {
2953 panic!("Expected account entity result");
2954 };
2955
2956 let base_time = Utc::now();
2957 for i in 0..25 {
2958 let post_date = base_time - Duration::days(i);
2959 create_test_transaction(
2960 user,
2961 account1.id,
2962 account2.id,
2963 commodity_id,
2964 post_date,
2965 100 + i,
2966 )
2967 .await?;
2968 }
2969
2970 if let Some(CmdResult::TaggedTransactions {
2972 entities,
2973 pagination: Some(pagination),
2974 }) = ListTransactions::new()
2975 .user_id(user.id)
2976 .limit(10)
2977 .run()
2978 .await?
2979 {
2980 assert_eq!(entities.len(), 10, "Expected exactly 10 transactions");
2981 assert_eq!(pagination.total_count, 25);
2982 assert_eq!(pagination.limit, 10);
2983 assert_eq!(pagination.offset, 0);
2984 assert!(pagination.has_more);
2985 } else {
2986 panic!("Expected TaggedTransactions result with pagination");
2987 }
2988
2989 if let Some(CmdResult::TaggedTransactions {
2991 entities,
2992 pagination: Some(pagination),
2993 }) = ListTransactions::new()
2994 .user_id(user.id)
2995 .limit(5)
2996 .run()
2997 .await?
2998 {
2999 assert_eq!(entities.len(), 5, "Expected exactly 5 transactions");
3000 assert_eq!(pagination.total_count, 25);
3001 assert!(pagination.has_more);
3002 } else {
3003 panic!("Expected TaggedTransactions result with pagination");
3004 }
3005
3006 if let Some(CmdResult::TaggedTransactions {
3008 entities,
3009 pagination: Some(pagination),
3010 }) = ListTransactions::new()
3011 .user_id(user.id)
3012 .limit(100)
3013 .run()
3014 .await?
3015 {
3016 assert_eq!(entities.len(), 25, "Expected all 25 transactions");
3017 assert_eq!(pagination.total_count, 25);
3018 assert!(!pagination.has_more);
3019 } else {
3020 panic!("Expected TaggedTransactions result with pagination");
3021 }
3022 }
3023
3024 #[local_db_sqlx_test]
3025 async fn test_pagination_offset(pool: PgPool) -> anyhow::Result<()> {
3026 let user = USER.get().unwrap();
3027 user.commit()
3028 .await
3029 .expect("Failed to commit user to database");
3030
3031 let commodity_result = CreateCommodity::new()
3032 .symbol("TST".to_string())
3033 .name("Test Commodity".to_string())
3034 .user_id(user.id)
3035 .run()
3036 .await?;
3037
3038 let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
3039 uuid::Uuid::parse_str(&id)?
3040 } else {
3041 panic!("Expected commodity ID string result");
3042 };
3043
3044 let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
3045 CreateAccount::new()
3046 .name("Account 1".to_string())
3047 .user_id(user.id)
3048 .run()
3049 .await?
3050 {
3051 account
3052 } else {
3053 panic!("Expected account entity result");
3054 };
3055
3056 let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
3057 CreateAccount::new()
3058 .name("Account 2".to_string())
3059 .user_id(user.id)
3060 .run()
3061 .await?
3062 {
3063 account
3064 } else {
3065 panic!("Expected account entity result");
3066 };
3067
3068 let base_time = Utc::now();
3069 for i in 0..25 {
3070 let post_date = base_time - Duration::days(i);
3071 create_test_transaction(
3072 user,
3073 account1.id,
3074 account2.id,
3075 commodity_id,
3076 post_date,
3077 100 + i,
3078 )
3079 .await?;
3080 }
3081
3082 if let Some(CmdResult::TaggedTransactions {
3084 entities,
3085 pagination: Some(pagination),
3086 }) = ListTransactions::new()
3087 .user_id(user.id)
3088 .limit(10)
3089 .offset(10)
3090 .run()
3091 .await?
3092 {
3093 assert_eq!(
3094 entities.len(),
3095 10,
3096 "Expected 10 transactions on second page"
3097 );
3098 assert_eq!(pagination.total_count, 25);
3099 assert_eq!(pagination.offset, 10);
3100 assert!(pagination.has_more);
3101 } else {
3102 panic!("Expected TaggedTransactions result with pagination");
3103 }
3104
3105 if let Some(CmdResult::TaggedTransactions {
3107 entities,
3108 pagination: Some(pagination),
3109 }) = ListTransactions::new()
3110 .user_id(user.id)
3111 .limit(10)
3112 .offset(20)
3113 .run()
3114 .await?
3115 {
3116 assert_eq!(entities.len(), 5, "Expected 5 transactions on last page");
3117 assert_eq!(pagination.total_count, 25);
3118 assert!(!pagination.has_more);
3119 } else {
3120 panic!("Expected TaggedTransactions result with pagination");
3121 }
3122
3123 if let Some(CmdResult::TaggedTransactions {
3125 entities,
3126 pagination: Some(pagination),
3127 }) = ListTransactions::new()
3128 .user_id(user.id)
3129 .limit(10)
3130 .offset(100)
3131 .run()
3132 .await?
3133 {
3134 assert!(entities.is_empty(), "Expected no transactions beyond total");
3135 assert_eq!(pagination.total_count, 25);
3136 assert!(!pagination.has_more);
3137 } else {
3138 panic!("Expected TaggedTransactions result with pagination");
3139 }
3140 }
3141
3142 #[local_db_sqlx_test]
3143 async fn test_pagination_date_filter(pool: PgPool) -> anyhow::Result<()> {
3144 let user = USER.get().unwrap();
3145 user.commit()
3146 .await
3147 .expect("Failed to commit user to database");
3148
3149 let commodity_result = CreateCommodity::new()
3150 .symbol("TST".to_string())
3151 .name("Test Commodity".to_string())
3152 .user_id(user.id)
3153 .run()
3154 .await?;
3155
3156 let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
3157 uuid::Uuid::parse_str(&id)?
3158 } else {
3159 panic!("Expected commodity ID string result");
3160 };
3161
3162 let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
3163 CreateAccount::new()
3164 .name("Account 1".to_string())
3165 .user_id(user.id)
3166 .run()
3167 .await?
3168 {
3169 account
3170 } else {
3171 panic!("Expected account entity result");
3172 };
3173
3174 let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
3175 CreateAccount::new()
3176 .name("Account 2".to_string())
3177 .user_id(user.id)
3178 .run()
3179 .await?
3180 {
3181 account
3182 } else {
3183 panic!("Expected account entity result");
3184 };
3185
3186 let base_time = Utc::now();
3187 for i in 0..30 {
3188 let post_date = base_time - Duration::days(i);
3189 create_test_transaction(
3190 user,
3191 account1.id,
3192 account2.id,
3193 commodity_id,
3194 post_date,
3195 100 + i,
3196 )
3197 .await?;
3198 }
3199
3200 let date_from = base_time - Duration::days(9);
3202 if let Some(CmdResult::TaggedTransactions {
3203 entities,
3204 pagination: Some(pagination),
3205 }) = ListTransactions::new()
3206 .user_id(user.id)
3207 .date_from(date_from)
3208 .run()
3209 .await?
3210 {
3211 assert_eq!(
3212 entities.len(),
3213 10,
3214 "Expected 10 transactions from last 10 days"
3215 );
3216 assert_eq!(pagination.total_count, 10);
3217 } else {
3218 panic!("Expected TaggedTransactions result with pagination");
3219 }
3220
3221 let date_to = base_time - Duration::days(20);
3223 if let Some(CmdResult::TaggedTransactions {
3224 entities,
3225 pagination: Some(pagination),
3226 }) = ListTransactions::new()
3227 .user_id(user.id)
3228 .date_to(date_to)
3229 .run()
3230 .await?
3231 {
3232 assert_eq!(
3233 entities.len(),
3234 10,
3235 "Expected 10 transactions older than 20 days"
3236 );
3237 assert_eq!(pagination.total_count, 10);
3238 } else {
3239 panic!("Expected TaggedTransactions result with pagination");
3240 }
3241
3242 let date_from = base_time - Duration::days(19);
3244 let date_to = base_time - Duration::days(10);
3245 if let Some(CmdResult::TaggedTransactions {
3246 entities,
3247 pagination: Some(pagination),
3248 }) = ListTransactions::new()
3249 .user_id(user.id)
3250 .date_from(date_from)
3251 .date_to(date_to)
3252 .run()
3253 .await?
3254 {
3255 assert_eq!(entities.len(), 10, "Expected 10 transactions in date range");
3256 assert_eq!(pagination.total_count, 10);
3257 } else {
3258 panic!("Expected TaggedTransactions result with pagination");
3259 }
3260
3261 let date_from = base_time - Duration::days(29);
3263 if let Some(CmdResult::TaggedTransactions {
3264 entities,
3265 pagination: Some(pagination),
3266 }) = ListTransactions::new()
3267 .user_id(user.id)
3268 .date_from(date_from)
3269 .limit(5)
3270 .run()
3271 .await?
3272 {
3273 assert_eq!(entities.len(), 5, "Expected 5 transactions with limit");
3274 assert_eq!(pagination.total_count, 30);
3275 assert!(pagination.has_more);
3276 } else {
3277 panic!("Expected TaggedTransactions result with pagination");
3278 }
3279 }
3280
3281 #[local_db_sqlx_test]
3282 async fn test_create_transaction_rejects_foreign_split_price(
3283 pool: PgPool,
3284 ) -> anyhow::Result<()> {
3285 let user = USER.get().unwrap();
3286 user.commit()
3287 .await
3288 .expect("Failed to commit user to database");
3289
3290 let commodity_result = CreateCommodity::new()
3291 .symbol("TST".to_string())
3292 .name("Test Commodity".to_string())
3293 .user_id(user.id)
3294 .run()
3295 .await?;
3296 let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
3297 uuid::Uuid::parse_str(&id)?
3298 } else {
3299 panic!("Expected commodity ID string result");
3300 };
3301
3302 let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
3303 CreateAccount::new()
3304 .name("Account 1".to_string())
3305 .user_id(user.id)
3306 .run()
3307 .await?
3308 {
3309 account
3310 } else {
3311 panic!("Expected account entity result");
3312 };
3313 let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
3314 CreateAccount::new()
3315 .name("Account 2".to_string())
3316 .user_id(user.id)
3317 .run()
3318 .await?
3319 {
3320 account
3321 } else {
3322 panic!("Expected account entity result");
3323 };
3324
3325 let tx_id = Uuid::new_v4();
3326 let split1_id = Uuid::new_v4();
3327 let split2_id = Uuid::new_v4();
3328 let now = Utc::now();
3329 let split1 = Split {
3330 id: split1_id,
3331 tx_id,
3332 account_id: account1.id,
3333 commodity_id,
3334 value_num: -100,
3335 value_denom: 1,
3336 reconcile_state: None,
3337 reconcile_date: None,
3338 lot_id: None,
3339 };
3340 let split2 = Split {
3341 id: split2_id,
3342 tx_id,
3343 account_id: account2.id,
3344 commodity_id,
3345 value_num: 100,
3346 value_denom: 1,
3347 reconcile_state: None,
3348 reconcile_date: None,
3349 lot_id: None,
3350 };
3351
3352 let foreign_split_id = Uuid::new_v4();
3354 let price = Price {
3355 id: Uuid::new_v4(),
3356 commodity_id,
3357 currency_id: commodity_id,
3358 commodity_split: Some(split1_id),
3359 currency_split: Some(foreign_split_id),
3360 date: now,
3361 value_num: 100,
3362 value_denom: 100,
3363 };
3364
3365 let result = CreateTransaction::new()
3366 .user_id(user.id)
3367 .splits(vec![
3368 FinanceEntity::Split(split1),
3369 FinanceEntity::Split(split2),
3370 ])
3371 .id(tx_id)
3372 .post_date(now)
3373 .enter_date(now)
3374 .prices(vec![FinanceEntity::Price(price)])
3375 .run()
3376 .await;
3377
3378 assert!(
3379 result.is_err(),
3380 "Expected error for price referencing a foreign split"
3381 );
3382
3383 let mut conn = user.get_connection().await?;
3384 let tx_count = sqlx::query!(
3385 "SELECT COUNT(*) as count FROM transactions WHERE id = $1",
3386 tx_id
3387 )
3388 .fetch_one(&mut *conn)
3389 .await?
3390 .count
3391 .unwrap_or(0);
3392 assert_eq!(
3393 tx_count, 0,
3394 "No transaction must persist on validation error"
3395 );
3396
3397 let split_count = sqlx::query!(
3398 "SELECT COUNT(*) as count FROM splits WHERE tx_id = $1",
3399 tx_id
3400 )
3401 .fetch_one(&mut *conn)
3402 .await?
3403 .count
3404 .unwrap_or(0);
3405 assert_eq!(split_count, 0, "No splits must persist on validation error");
3406 }
3407}
3408
3409#[cfg(test)]
3410mod aggregate_tests {
3411 use super::{SplitAmountRow, aggregate_split_amounts};
3412 use sqlx::types::Uuid;
3413
3414 fn commodity_for(symbol: &str) -> Uuid {
3416 let n = symbol.bytes().fold(0u128, |acc, b| {
3417 acc.wrapping_mul(31).wrapping_add(u128::from(b))
3418 });
3419 Uuid::from_u128(n | 1)
3420 }
3421
3422 fn row(tx_id: Uuid, num: i64, denom: i64, symbol: &str) -> SplitAmountRow {
3423 row_cid(tx_id, commodity_for(symbol), num, denom, symbol)
3424 }
3425
3426 fn row_cid(
3427 tx_id: Uuid,
3428 commodity_id: Uuid,
3429 num: i64,
3430 denom: i64,
3431 symbol: &str,
3432 ) -> SplitAmountRow {
3433 SplitAmountRow {
3434 tx_id,
3435 commodity_id,
3436 value_num: num,
3437 value_denom: denom,
3438 symbol: symbol.to_string(),
3439 }
3440 }
3441
3442 #[test]
3443 fn same_symbol_distinct_commodities_stay_separate() {
3444 let tx = Uuid::new_v4();
3445 let result = aggregate_split_amounts(vec![
3446 row_cid(tx, Uuid::from_u128(1), 100, 1, "USD"),
3447 row_cid(tx, Uuid::from_u128(2), 50, 1, "USD"),
3448 ]);
3449 assert_eq!(result.get(&tx).map(String::as_str), Some("100 USD; 50 USD"));
3450 }
3451
3452 #[test]
3453 fn empty_input_returns_empty_map() {
3454 assert!(aggregate_split_amounts(vec![]).is_empty());
3455 }
3456
3457 #[test]
3458 fn single_commodity_sums_correctly() {
3459 let id = Uuid::new_v4();
3460 let result = aggregate_split_amounts(vec![row(id, 50, 1, "USD"), row(id, 50, 1, "USD")]);
3461 assert_eq!(result.get(&id).map(String::as_str), Some("100 USD"));
3462 }
3463
3464 #[test]
3465 fn multi_commodity_formats_sorted() {
3466 let id = Uuid::new_v4();
3467 let result = aggregate_split_amounts(vec![row(id, 100, 1, "USD"), row(id, 50, 1, "EUR")]);
3468 assert_eq!(result.get(&id).map(String::as_str), Some("50 EUR; 100 USD"));
3469 }
3470
3471 #[test]
3472 fn fractional_amount_formatted() {
3473 let id = Uuid::new_v4();
3474 let result = aggregate_split_amounts(vec![row(id, 1, 3, "BTC")]);
3475 assert_eq!(result.get(&id).map(String::as_str), Some("1/3 BTC"));
3476 }
3477
3478 #[test]
3479 fn multiple_transactions_independent() {
3480 let id1 = Uuid::new_v4();
3481 let id2 = Uuid::new_v4();
3482 let result =
3483 aggregate_split_amounts(vec![row(id1, 100, 1, "USD"), row(id2, 200, 1, "EUR")]);
3484 assert_eq!(result.get(&id1).map(String::as_str), Some("100 USD"));
3485 assert_eq!(result.get(&id2).map(String::as_str), Some("200 EUR"));
3486 }
3487}