Skip to main content

server/
script.rs

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/// One per-script failure observation from a batch `run_scripts` invocation.
18/// `code` is a kebab-case symbol mirroring the wire envelope of catch-each
19/// result cells (so emacs / cli / web clients render script failures the
20/// same shape regardless of whether the script raised inside catch-each
21/// or surfaced at the outer batch boundary). `message` is the engine's
22/// diagnostic.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct ScriptFailure {
25    pub script_id: Uuid,
26    pub code: String,
27    pub message: String,
28}
29
30/// Aggregate output of a batch `run_scripts` call. `state` is the final
31/// `TransactionState` after every script that succeeded was applied;
32/// `failures` lists every script that errored (in execution order).
33/// Failures don't abort the run — successive scripts still see the
34/// state mutations from previous successful ones.
35pub 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    /// Display name per posting account id, so the serializer can expose
51    /// `SPLIT-ACCOUNT-NAME` to trigger scripts without an in-script account
52    /// lookup. Empty for an account whose name is unknown at build time.
53    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    /// Runs each script against the current state, accumulating
108    /// per-script failures into a structured `ScriptRunReport` rather
109    /// than swallowing them silently. The `state` field reflects every
110    /// script that successfully produced entities; failed scripts are
111    /// recorded in `failures` and don't abort the batch — subsequent
112    /// scripts still observe state mutations from earlier successes.
113    ///
114    /// The outer `Result` reserves `ServerError` for genuine
115    /// orchestration failures (entity-apply errors), keeping
116    /// script-side failures structurally separate. Callers that want
117    /// the previous "first-failure-aborts" semantics can chain with
118    /// `ScriptRunReport::into_state_or_first_failure`.
119    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
144/// Builds a [`TransactionState`] for a single transaction id by
145/// fetching the transaction, its splits, and any existing `note`
146/// tag through the public `server::command::*` API. Used by the
147/// batch-script runner to feed a per-transaction state to
148/// `run_scripts` without re-implementing the read path.
149///
150/// Each query trips through the typestate runners in
151/// `server::command::*`, so behaviour matches what the rpc
152/// natives surface — single dispatch surface honored.
153pub 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
204/// Resolves the display name (the `name` tag) of every distinct posting
205/// account referenced by `split_entities`, through the public command API.
206async 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
460/// Maps a `HookError` from a single batch-script run into a structured
461/// `ScriptFailure`. Wasm-engine errors classify through the same
462/// `EngineError` pipeline catch-each uses (`OutOfFuel`, `ScriptRaised`,
463/// `NoConversion`, ...) so client renderers see one shape no matter where
464/// in the stack the failure originated. A commodity mismatch arrives as a
465/// `ScriptRaised{code:"commodity-mismatch"}` (it `throw`s `$nomi_error`
466/// in-guest; ADR-0026). Non-engine variants (Parse, Lock, ...) get a
467/// `runtime` code with the engine's own message.
468fn 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, // Points to transaction at index 0
541            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, // Points to split at index 1
587            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); // transaction + split
640        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    /// Bad bytecode lands as a structured `ScriptFailure` in the report
734    /// rather than being silently swallowed. The state is preserved
735    /// (no side effects from a failed script).
736    #[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    /// A mixed batch — one failing script followed by a working one —
772    /// captures the failure but still applies the second script's
773    /// output. Validates the "successive scripts still see state
774    /// mutations from earlier successes" invariant of the report shape.
775    /// Setup mirrors `test_tag_sync_copies_split_tags_to_transaction`'s
776    /// two-split balanced shape because tag_sync.wasm only fires once
777    /// the transaction's splits sum to zero.
778    #[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    /// `classify_script_failure` routes engine-classified errors
859    /// through `err_code_and_message` so the code symbols match catch-each's
860    /// err cells. A commodity mismatch is the load-bearing case — it now
861    /// `throw`s `$nomi_error` in-guest and arrives as a `ScriptRaised`
862    /// carrying the reader-folded symbol `COMMODITY-MISMATCH` (ADR-0026),
863    /// the structural signal scripts react to, distinct from generic traps.
864    #[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    /// Non-engine `HookError` variants (Parse / Lock / Script) fall
880    /// through to a `runtime` code with the engine's own message —
881    /// the catch-all path that keeps `ScriptFailure`'s shape
882    /// non-empty for any cause `executor.execute` can surface.
883    #[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}