1use finance::price::Price;
2use finance::split::Split;
3use finance::tag::Tag;
4use finance::transaction::Transaction;
5use scripting::error::HookError;
6use scripting::runtime::{classify_runtime_error, err_code_and_message};
7use scripting::{
8 ContextType, EntityData, EntityType, MemorySerializer, Operation, ParsedEntity, ScriptExecutor,
9};
10use sqlx::types::Uuid;
11use sqlx::types::chrono::{DateTime, TimeZone, Utc};
12use std::collections::HashMap;
13
14use crate::command::FinanceEntity;
15use crate::error::ServerError;
16
17#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct ScriptFailure {
25 pub script_id: Uuid,
26 pub code: String,
27 pub message: String,
28}
29
30pub struct ScriptRunReport {
36 pub state: TransactionState,
37 pub failures: Vec<ScriptFailure>,
38}
39
40const DEFAULT_OUTPUT_SIZE: u32 = 64 * 1024;
41
42type IndexTable = HashMap<u32, (EntityType, Uuid)>;
43
44pub struct TransactionState {
45 pub transaction: Transaction,
46 pub splits: Vec<Split>,
47 pub transaction_tags: Vec<Tag>,
48 pub split_tags: Vec<(Uuid, Tag)>,
49 pub prices: Vec<Price>,
50 pub account_names: HashMap<Uuid, String>,
54}
55
56impl TransactionState {
57 #[must_use]
58 pub fn new(transaction: Transaction) -> Self {
59 Self {
60 transaction,
61 splits: Vec::new(),
62 transaction_tags: Vec::new(),
63 split_tags: Vec::new(),
64 prices: Vec::new(),
65 account_names: HashMap::new(),
66 }
67 }
68
69 #[must_use]
70 pub fn with_account_names(mut self, names: HashMap<Uuid, String>) -> Self {
71 self.account_names = names;
72 self
73 }
74
75 #[must_use]
76 pub fn with(mut self, entities: Vec<FinanceEntity>) -> Self {
77 for entity in entities {
78 match entity {
79 FinanceEntity::Split(s) => self.splits.push(s),
80 FinanceEntity::Price(p) => self.prices.push(p),
81 FinanceEntity::Tag(t) => self.transaction_tags.push(t),
82 _ => {}
83 }
84 }
85 self
86 }
87
88 #[must_use]
89 pub fn with_split_tags(mut self, tags: Vec<(Uuid, Tag)>) -> Self {
90 self.split_tags = tags;
91 self
92 }
93
94 #[must_use]
95 pub fn with_note(mut self, note: Option<String>) -> Self {
96 if let Some(note) = note {
97 self.transaction_tags.push(Tag {
98 id: Uuid::new_v4(),
99 tag_name: "note".to_string(),
100 tag_value: note,
101 description: None,
102 });
103 }
104 self
105 }
106
107 pub fn run_scripts(
120 mut self,
121 executor: &ScriptExecutor,
122 scripts: &[(Uuid, Vec<u8>)],
123 ) -> Result<ScriptRunReport, ServerError> {
124 let mut failures: Vec<ScriptFailure> = Vec::new();
125 for (script_id, bytecode) in scripts {
126 let (input, mut index_table) = serialize_state(&self);
127 match executor.execute(bytecode, &input, Some(DEFAULT_OUTPUT_SIZE)) {
128 Ok(entities) if !entities.is_empty() => {
129 apply_parsed_entities(&mut self, entities, &mut index_table)?;
130 }
131 Ok(_) => {}
132 Err(e) => {
133 failures.push(classify_script_failure(*script_id, &e));
134 }
135 }
136 }
137 Ok(ScriptRunReport {
138 state: self,
139 failures,
140 })
141 }
142}
143
144pub async fn load_transaction_state(
154 user_id: Uuid,
155 transaction_id: Uuid,
156) -> Result<Option<TransactionState>, ServerError> {
157 use crate::command::transaction::GetTransaction;
158 use crate::command::{CmdResult, FinanceEntity};
159
160 let tx_result = GetTransaction::new()
161 .user_id(user_id)
162 .transaction_id(transaction_id)
163 .run()
164 .await
165 .map_err(|e| ServerError::Script(format!("get-transaction {transaction_id}: {e:?}")))?;
166 let Some(CmdResult::TaggedEntities { mut entities, .. }) = tx_result else {
167 return Ok(None);
168 };
169 let Some((FinanceEntity::Transaction(tx), tags)) = entities.pop() else {
170 return Ok(None);
171 };
172 let note = tags.get("note").and_then(|entity| match entity {
173 FinanceEntity::Tag(tag) => Some(tag.tag_value.clone()),
174 _ => None,
175 });
176
177 let splits_result = crate::command::split::ListSplits::new()
178 .user_id(user_id)
179 .transaction(transaction_id)
180 .run()
181 .await
182 .map_err(|e| ServerError::Script(format!("list-splits {transaction_id}: {e:?}")))?;
183 let mut split_entities: Vec<FinanceEntity> = Vec::new();
184 if let Some(CmdResult::TaggedEntities {
185 entities: split_data,
186 ..
187 }) = splits_result
188 {
189 for (entity, _tags) in split_data {
190 split_entities.push(entity);
191 }
192 }
193
194 let account_names = load_account_names(user_id, &split_entities).await?;
195
196 Ok(Some(
197 TransactionState::new(tx)
198 .with(split_entities)
199 .with_note(note)
200 .with_account_names(account_names),
201 ))
202}
203
204async fn load_account_names(
207 user_id: Uuid,
208 split_entities: &[FinanceEntity],
209) -> Result<HashMap<Uuid, String>, ServerError> {
210 use crate::command::account::GetAccount;
211 use crate::command::{CmdResult, FinanceEntity};
212
213 let mut names: HashMap<Uuid, String> = HashMap::new();
214 for entity in split_entities {
215 let FinanceEntity::Split(split) = entity else {
216 continue;
217 };
218 if names.contains_key(&split.account_id) {
219 continue;
220 }
221 let result = GetAccount::new()
222 .user_id(user_id)
223 .account_id(split.account_id)
224 .run()
225 .await
226 .map_err(|e| ServerError::Script(format!("get-account {}: {e:?}", split.account_id)))?;
227 if let Some(CmdResult::TaggedEntities { entities, .. }) = result
228 && let Some((_, tags)) = entities.first()
229 && let Some(FinanceEntity::Tag(tag)) = tags.get("name")
230 {
231 names.insert(split.account_id, tag.tag_value.clone());
232 }
233 }
234 Ok(names)
235}
236
237fn serialize_state(state: &TransactionState) -> (Vec<u8>, IndexTable) {
238 let mut serializer = MemorySerializer::new();
239 let mut index_table = IndexTable::new();
240
241 serializer.set_context(ContextType::EntityCreate, EntityType::Transaction);
242
243 let is_multi_currency = state
244 .splits
245 .iter()
246 .map(|s| s.commodity_id)
247 .collect::<std::collections::HashSet<_>>()
248 .len()
249 > 1;
250
251 let tx_idx = serializer.add_transaction_from(scripting::TransactionFromArgs {
252 transaction: &state.transaction,
253 is_primary: true,
254 split_count: state.splits.len() as u32,
255 tag_count: state.transaction_tags.len() as u32,
256 is_multi_currency,
257 });
258 serializer.set_primary(tx_idx);
259 index_table.insert(tx_idx, (EntityType::Transaction, state.transaction.id));
260
261 let mut split_indices: Vec<(Uuid, u32)> = Vec::new();
262
263 for split in &state.splits {
264 let account_name = state
265 .account_names
266 .get(&split.account_id)
267 .map_or("", String::as_str);
268 let split_idx = serializer.add_split_from(split, tx_idx as i32, account_name);
269 split_indices.push((split.id, split_idx));
270 index_table.insert(split_idx, (EntityType::Split, split.id));
271 }
272
273 for tag in &state.transaction_tags {
274 serializer.add_tag(
275 *tag.id.as_bytes(),
276 tx_idx as i32,
277 false,
278 false,
279 &tag.tag_name,
280 &tag.tag_value,
281 );
282 }
283
284 for (split_id, tag) in &state.split_tags {
285 let parent_idx = split_indices
286 .iter()
287 .find(|(id, _)| id == split_id)
288 .map_or(-1, |(_, idx)| *idx as i32);
289
290 serializer.add_tag(
291 *tag.id.as_bytes(),
292 parent_idx,
293 false,
294 false,
295 &tag.tag_name,
296 &tag.tag_value,
297 );
298 }
299
300 (serializer.finalize(DEFAULT_OUTPUT_SIZE), index_table)
301}
302
303fn apply_parsed_entities(
304 state: &mut TransactionState,
305 entities: Vec<ParsedEntity>,
306 index_table: &mut IndexTable,
307) -> Result<(), ServerError> {
308 let mut current_output_idx = index_table.len() as u32;
309
310 for entity in entities {
311 let entity_id = Uuid::from_bytes(entity.id);
312
313 match (entity.entity_type, entity.operation) {
314 (EntityType::Tag, Operation::Create) => {
315 if let EntityData::Tag { name, value } = entity.data {
316 let tag_id = Uuid::new_v4();
317 let tag = Tag {
318 id: tag_id,
319 tag_name: name.clone(),
320 tag_value: value.clone(),
321 description: None,
322 };
323
324 match index_table.get(&(entity.parent_idx as u32)) {
325 Some(&(EntityType::Transaction, tx_id)) => {
326 log::debug!(
327 "script: create tag \"{name}\"=\"{value}\" on transaction {tx_id}"
328 );
329 state.transaction_tags.push(tag);
330 }
331 Some(&(EntityType::Split, split_id)) => {
332 log::debug!(
333 "script: create tag \"{name}\"=\"{value}\" on split {split_id}"
334 );
335 state.split_tags.push((split_id, tag));
336 }
337 _ => {
338 log::warn!(
339 "Tag parent_idx {} not found in index table",
340 entity.parent_idx
341 );
342 }
343 }
344
345 index_table.insert(current_output_idx, (EntityType::Tag, tag_id));
346 current_output_idx += 1;
347 }
348 }
349 (EntityType::Split, Operation::Create) => {
350 if let EntityData::Split {
351 account_id,
352 commodity_id,
353 value_num,
354 value_denom,
355 reconcile_state,
356 reconcile_date,
357 } = entity.data
358 {
359 let account_id = Uuid::from_bytes(account_id);
360 let commodity_id = Uuid::from_bytes(commodity_id);
361 let split_id = Uuid::new_v4();
362 log::debug!(
363 "script: create split {split_id} account={account_id} value={value_num}/{value_denom}"
364 );
365 let split = Split {
366 id: split_id,
367 tx_id: state.transaction.id,
368 account_id,
369 commodity_id,
370 value_num,
371 value_denom,
372 reconcile_state: if reconcile_state == 0 {
373 None
374 } else {
375 Some(reconcile_state != 0)
376 },
377 reconcile_date: if reconcile_date == 0 {
378 None
379 } else {
380 Some(
381 Utc.timestamp_millis_opt(reconcile_date)
382 .single()
383 .unwrap_or_default(),
384 )
385 },
386 lot_id: None,
387 };
388 state.splits.push(split);
389
390 index_table.insert(current_output_idx, (EntityType::Split, split_id));
391 current_output_idx += 1;
392 }
393 }
394 (EntityType::Split, Operation::Update) => {
395 if let EntityData::Split {
396 account_id,
397 commodity_id,
398 value_num,
399 value_denom,
400 reconcile_state,
401 reconcile_date,
402 } = entity.data
403 && let Some(split) = state.splits.iter_mut().find(|s| s.id == entity_id)
404 {
405 log::debug!("script: update split {entity_id} value={value_num}/{value_denom}");
406 split.account_id = Uuid::from_bytes(account_id);
407 split.commodity_id = Uuid::from_bytes(commodity_id);
408 split.value_num = value_num;
409 split.value_denom = value_denom;
410 split.reconcile_state = if reconcile_state == 0 {
411 None
412 } else {
413 Some(reconcile_state != 0)
414 };
415 split.reconcile_date = if reconcile_date == 0 {
416 None
417 } else {
418 Some(
419 Utc.timestamp_millis_opt(reconcile_date)
420 .single()
421 .unwrap_or_default(),
422 )
423 };
424 }
425 }
426 (EntityType::Transaction, Operation::Update) => {
427 if let EntityData::Transaction {
428 post_date,
429 enter_date,
430 ..
431 } = entity.data
432 {
433 log::debug!("script: update transaction {entity_id}");
434 state.transaction.post_date = millis_to_datetime(post_date);
435 state.transaction.enter_date = millis_to_datetime(enter_date);
436 }
437 }
438 (EntityType::Split, Operation::Delete) => {
439 log::debug!("script: delete split {entity_id}");
440 state.splits.retain(|s| s.id != entity_id);
441 state.split_tags.retain(|(id, _)| *id != entity_id);
442 }
443 (EntityType::Tag, Operation::Delete) => {
444 log::debug!("script: delete tag {entity_id}");
445 state.transaction_tags.retain(|t| t.id != entity_id);
446 state.split_tags.retain(|(_, t)| t.id != entity_id);
447 }
448 _ => {}
449 }
450 }
451 Ok(())
452}
453
454fn millis_to_datetime(millis: i64) -> DateTime<Utc> {
455 Utc.timestamp_millis_opt(millis)
456 .single()
457 .unwrap_or_default()
458}
459
460fn classify_script_failure(script_id: Uuid, err: &HookError) -> ScriptFailure {
469 let (code, message) = match err {
470 HookError::WASM(wasm_err) => err_code_and_message(&classify_runtime_error(wasm_err)),
471 HookError::Engine(engine_err) => err_code_and_message(engine_err),
472 other => ("runtime".to_string(), format!("{other}")),
473 };
474 ScriptFailure {
475 script_id,
476 code,
477 message,
478 }
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484 use finance::transaction::TransactionBuilder;
485 use sqlx::types::chrono::Local;
486
487 #[test]
488 fn test_transaction_state_new() {
489 let tx = TransactionBuilder::new()
490 .id(Uuid::new_v4())
491 .post_date(Local::now().into())
492 .enter_date(Local::now().into())
493 .build()
494 .unwrap();
495
496 let state = TransactionState::new(tx);
497 assert!(state.splits.is_empty());
498 assert!(state.transaction_tags.is_empty());
499 assert!(state.split_tags.is_empty());
500 assert!(state.prices.is_empty());
501 }
502
503 #[test]
504 fn test_serialize_empty_state() {
505 let tx = TransactionBuilder::new()
506 .id(Uuid::new_v4())
507 .post_date(Local::now().into())
508 .enter_date(Local::now().into())
509 .build()
510 .unwrap();
511
512 let state = TransactionState::new(tx);
513 let (bytes, index_table) = serialize_state(&state);
514 assert!(!bytes.is_empty());
515 assert!(
516 index_table.contains_key(&0),
517 "transaction missing at index 0"
518 );
519 }
520
521 #[test]
522 fn test_apply_tag_to_transaction() {
523 let tx_id = Uuid::new_v4();
524 let tx = TransactionBuilder::new()
525 .id(tx_id)
526 .post_date(Local::now().into())
527 .enter_date(Local::now().into())
528 .build()
529 .unwrap();
530
531 let mut state = TransactionState::new(tx);
532 let mut index_table = IndexTable::new();
533 index_table.insert(0, (EntityType::Transaction, tx_id));
534
535 let tag_entity = ParsedEntity {
536 entity_type: EntityType::Tag,
537 operation: Operation::Create,
538 flags: 0,
539 id: *Uuid::new_v4().as_bytes(),
540 parent_idx: 0, data: EntityData::Tag {
542 name: "category".to_string(),
543 value: "groceries".to_string(),
544 },
545 };
546
547 apply_parsed_entities(&mut state, vec![tag_entity], &mut index_table).unwrap();
548 assert_eq!(state.transaction_tags.len(), 1);
549 assert_eq!(state.transaction_tags[0].tag_name, "category");
550 assert_eq!(state.transaction_tags[0].tag_value, "groceries");
551 }
552
553 #[test]
554 fn test_apply_tag_to_split() {
555 let tx_id = Uuid::new_v4();
556 let split_id = Uuid::new_v4();
557 let tx = TransactionBuilder::new()
558 .id(tx_id)
559 .post_date(Local::now().into())
560 .enter_date(Local::now().into())
561 .build()
562 .unwrap();
563
564 let split = Split {
565 id: split_id,
566 tx_id,
567 account_id: Uuid::new_v4(),
568 commodity_id: Uuid::new_v4(),
569 value_num: 100,
570 value_denom: 1,
571 reconcile_state: None,
572 reconcile_date: None,
573 lot_id: None,
574 };
575
576 let mut state = TransactionState::new(tx).with(vec![FinanceEntity::Split(split)]);
577 let mut index_table = IndexTable::new();
578 index_table.insert(0, (EntityType::Transaction, tx_id));
579 index_table.insert(1, (EntityType::Split, split_id));
580
581 let tag_entity = ParsedEntity {
582 entity_type: EntityType::Tag,
583 operation: Operation::Create,
584 flags: 0,
585 id: *Uuid::new_v4().as_bytes(),
586 parent_idx: 1, data: EntityData::Tag {
588 name: "category".to_string(),
589 value: "groceries".to_string(),
590 },
591 };
592
593 apply_parsed_entities(&mut state, vec![tag_entity], &mut index_table).unwrap();
594 assert_eq!(state.split_tags.len(), 1);
595 assert_eq!(state.split_tags[0].0, split_id);
596 assert_eq!(state.split_tags[0].1.tag_name, "category");
597 assert_eq!(state.split_tags[0].1.tag_value, "groceries");
598 }
599
600 #[test]
601 fn test_serialize_state_with_split_tags() {
602 let tx_id = Uuid::new_v4();
603 let split_id = Uuid::new_v4();
604 let tx = TransactionBuilder::new()
605 .id(tx_id)
606 .post_date(Local::now().into())
607 .enter_date(Local::now().into())
608 .build()
609 .unwrap();
610
611 let split = Split {
612 id: split_id,
613 tx_id,
614 account_id: Uuid::new_v4(),
615 commodity_id: Uuid::new_v4(),
616 value_num: 100,
617 value_denom: 1,
618 reconcile_state: None,
619 reconcile_date: None,
620 lot_id: None,
621 };
622
623 let tag = Tag {
624 id: Uuid::new_v4(),
625 tag_name: "category".to_string(),
626 tag_value: "food".to_string(),
627 description: None,
628 };
629
630 let state = TransactionState::new(tx)
631 .with(vec![FinanceEntity::Split(split)])
632 .with_split_tags(vec![(split_id, tag)]);
633
634 assert_eq!(state.split_tags.len(), 1);
635 assert_eq!(state.split_tags[0].0, split_id);
636
637 let (bytes, index_table) = serialize_state(&state);
638 assert!(!bytes.is_empty());
639 assert_eq!(index_table.len(), 2); assert_eq!(index_table.get(&1).unwrap(), &(EntityType::Split, split_id));
641 }
642
643 #[test]
644 fn test_millis_to_datetime() {
645 let dt = millis_to_datetime(1704067200000);
646 assert_eq!(dt.timestamp(), 1704067200);
647 }
648
649 #[test]
650 fn test_tag_sync_copies_split_tags_to_transaction() {
651 const TAG_SYNC_WASM: &[u8] = include_bytes!("../../web/static/wasm/tag_sync.wasm");
652
653 let tx_id = Uuid::new_v4();
654 let split1_id = Uuid::new_v4();
655 let split2_id = Uuid::new_v4();
656 let commodity_id = Uuid::new_v4();
657
658 let tx = TransactionBuilder::new()
659 .id(tx_id)
660 .post_date(Local::now().into())
661 .enter_date(Local::now().into())
662 .build()
663 .unwrap();
664
665 let split1 = Split {
666 id: split1_id,
667 tx_id,
668 account_id: Uuid::new_v4(),
669 commodity_id,
670 value_num: -5000,
671 value_denom: 100,
672 reconcile_state: None,
673 reconcile_date: None,
674 lot_id: None,
675 };
676
677 let split2 = Split {
678 id: split2_id,
679 tx_id,
680 account_id: Uuid::new_v4(),
681 commodity_id,
682 value_num: 5000,
683 value_denom: 100,
684 reconcile_state: None,
685 reconcile_date: None,
686 lot_id: None,
687 };
688
689 let category_tag = Tag {
690 id: Uuid::new_v4(),
691 tag_name: "category".to_string(),
692 tag_value: "food".to_string(),
693 description: None,
694 };
695
696 let state = TransactionState::new(tx)
697 .with(vec![
698 FinanceEntity::Split(split1),
699 FinanceEntity::Split(split2),
700 ])
701 .with_note(Some("groceries".to_string()))
702 .with_split_tags(vec![(split1_id, category_tag)]);
703
704 assert_eq!(state.transaction_tags.len(), 1);
705 assert_eq!(state.split_tags.len(), 1);
706
707 let script_id = Uuid::new_v4();
708 let executor = ScriptExecutor::new();
709 let report = state
710 .run_scripts(&executor, &[(script_id, TAG_SYNC_WASM.to_vec())])
711 .expect("run_scripts failed");
712 assert!(
713 report.failures.is_empty(),
714 "tag_sync.wasm must run cleanly: {:?}",
715 report.failures
716 );
717
718 let new_tx_tags: Vec<_> = report
719 .state
720 .transaction_tags
721 .iter()
722 .filter(|t| t.tag_name != "note")
723 .collect();
724 assert_eq!(
725 new_tx_tags.len(),
726 1,
727 "tag_sync should copy category tag from split to transaction"
728 );
729 assert_eq!(new_tx_tags[0].tag_name, "category");
730 assert_eq!(new_tx_tags[0].tag_value, "food");
731 }
732
733 #[test]
737 fn run_scripts_captures_failed_script_into_structured_report() {
738 let tx_id = Uuid::new_v4();
739 let tx = TransactionBuilder::new()
740 .id(tx_id)
741 .post_date(Local::now().into())
742 .enter_date(Local::now().into())
743 .build()
744 .unwrap();
745 let state = TransactionState::new(tx);
746
747 let script_id = Uuid::new_v4();
748 let invalid_bytecode: Vec<u8> = vec![0xde, 0xad, 0xbe, 0xef];
749 let executor = ScriptExecutor::new();
750 let report = state
751 .run_scripts(&executor, &[(script_id, invalid_bytecode)])
752 .expect("run_scripts must surface bad-bytecode as a failure cell, not an outer Err");
753
754 assert_eq!(
755 report.failures.len(),
756 1,
757 "single bad script should produce exactly one captured failure"
758 );
759 assert_eq!(report.failures[0].script_id, script_id);
760 assert!(
761 !report.failures[0].code.is_empty(),
762 "captured failure must carry a non-empty code symbol"
763 );
764 assert!(
765 report.state.transaction_tags.is_empty(),
766 "failed script must not have mutated state"
767 );
768 assert_eq!(report.state.transaction.id, tx_id);
769 }
770
771 #[test]
779 fn run_scripts_continues_past_failure_and_applies_subsequent_scripts() {
780 const TAG_SYNC_WASM: &[u8] = include_bytes!("../../web/static/wasm/tag_sync.wasm");
781
782 let tx_id = Uuid::new_v4();
783 let split1_id = Uuid::new_v4();
784 let split2_id = Uuid::new_v4();
785 let commodity_id = Uuid::new_v4();
786 let tx = TransactionBuilder::new()
787 .id(tx_id)
788 .post_date(Local::now().into())
789 .enter_date(Local::now().into())
790 .build()
791 .unwrap();
792 let split1 = Split {
793 id: split1_id,
794 tx_id,
795 account_id: Uuid::new_v4(),
796 commodity_id,
797 value_num: -5000,
798 value_denom: 100,
799 reconcile_state: None,
800 reconcile_date: None,
801 lot_id: None,
802 };
803 let split2 = Split {
804 id: split2_id,
805 tx_id,
806 account_id: Uuid::new_v4(),
807 commodity_id,
808 value_num: 5000,
809 value_denom: 100,
810 reconcile_state: None,
811 reconcile_date: None,
812 lot_id: None,
813 };
814 let category_tag = Tag {
815 id: Uuid::new_v4(),
816 tag_name: "category".to_string(),
817 tag_value: "food".to_string(),
818 description: None,
819 };
820 let state = TransactionState::new(tx)
821 .with(vec![
822 FinanceEntity::Split(split1),
823 FinanceEntity::Split(split2),
824 ])
825 .with_note(Some("groceries".to_string()))
826 .with_split_tags(vec![(split1_id, category_tag)]);
827
828 let bad_id = Uuid::new_v4();
829 let good_id = Uuid::new_v4();
830 let executor = ScriptExecutor::new();
831 let report = state
832 .run_scripts(
833 &executor,
834 &[
835 (bad_id, vec![0xde, 0xad, 0xbe, 0xef]),
836 (good_id, TAG_SYNC_WASM.to_vec()),
837 ],
838 )
839 .expect("run_scripts must capture per-script failures structurally");
840
841 assert_eq!(report.failures.len(), 1);
842 assert_eq!(report.failures[0].script_id, bad_id);
843
844 let category_tx_tags: Vec<_> = report
845 .state
846 .transaction_tags
847 .iter()
848 .filter(|t| t.tag_name == "category")
849 .collect();
850 assert_eq!(
851 category_tx_tags.len(),
852 1,
853 "tag_sync.wasm must still copy the split's category tag onto the transaction \
854 after an earlier script failed"
855 );
856 }
857
858 #[test]
865 fn classify_script_failure_maps_engine_error_to_symbol_code() {
866 use scripting::runtime::EngineError;
867 let engine_err = EngineError::ScriptRaised {
868 code: "COMMODITY-MISMATCH".to_string(),
869 message: "USD vs EUR".to_string(),
870 };
871 let hook_err = HookError::Engine(engine_err);
872 let id = Uuid::new_v4();
873 let failure = classify_script_failure(id, &hook_err);
874 assert_eq!(failure.script_id, id);
875 assert_eq!(failure.code, "COMMODITY-MISMATCH");
876 assert_eq!(failure.message, "USD vs EUR");
877 }
878
879 #[test]
884 fn classify_script_failure_falls_back_to_runtime_for_non_engine_error() {
885 let hook_err = HookError::Script("synthetic test failure".to_string());
886 let id = Uuid::new_v4();
887 let failure = classify_script_failure(id, &hook_err);
888 assert_eq!(failure.script_id, id);
889 assert_eq!(failure.code, "runtime");
890 assert!(failure.message.contains("synthetic test failure"));
891 }
892}