1
use finance::price::Price;
2
use finance::split::Split;
3
use finance::tag::Tag;
4
use finance::transaction::Transaction;
5
use scripting::error::HookError;
6
use scripting::runtime::{classify_runtime_error, err_code_and_message};
7
use scripting::{
8
    ContextType, EntityData, EntityType, MemorySerializer, Operation, ParsedEntity, ScriptExecutor,
9
};
10
use sqlx::types::Uuid;
11
use sqlx::types::chrono::{DateTime, TimeZone, Utc};
12
use std::collections::HashMap;
13

            
14
use crate::command::FinanceEntity;
15
use 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)]
24
pub 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.
35
pub struct ScriptRunReport {
36
    pub state: TransactionState,
37
    pub failures: Vec<ScriptFailure>,
38
}
39

            
40
const DEFAULT_OUTPUT_SIZE: u32 = 64 * 1024;
41

            
42
type IndexTable = HashMap<u32, (EntityType, Uuid)>;
43

            
44
pub 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

            
56
impl TransactionState {
57
    #[must_use]
58
1513
    pub fn new(transaction: Transaction) -> Self {
59
1513
        Self {
60
1513
            transaction,
61
1513
            splits: Vec::new(),
62
1513
            transaction_tags: Vec::new(),
63
1513
            split_tags: Vec::new(),
64
1513
            prices: Vec::new(),
65
1513
            account_names: HashMap::new(),
66
1513
        }
67
1513
    }
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
3014
    pub fn with(mut self, entities: Vec<FinanceEntity>) -> Self {
77
3201
        for entity in entities {
78
3201
            match entity {
79
3049
                FinanceEntity::Split(s) => self.splits.push(s),
80
152
                FinanceEntity::Price(p) => self.prices.push(p),
81
                FinanceEntity::Tag(t) => self.transaction_tags.push(t),
82
                _ => {}
83
            }
84
        }
85
3014
        self
86
3014
    }
87

            
88
    #[must_use]
89
1508
    pub fn with_split_tags(mut self, tags: Vec<(Uuid, Tag)>) -> Self {
90
1508
        self.split_tags = tags;
91
1508
        self
92
1508
    }
93

            
94
    #[must_use]
95
1507
    pub fn with_note(mut self, note: Option<String>) -> Self {
96
1507
        if let Some(note) = note {
97
647
            self.transaction_tags.push(Tag {
98
647
                id: Uuid::new_v4(),
99
647
                tag_name: "note".to_string(),
100
647
                tag_value: note,
101
647
                description: None,
102
647
            });
103
860
        }
104
1507
        self
105
1507
    }
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
6
    pub fn run_scripts(
120
6
        mut self,
121
6
        executor: &ScriptExecutor,
122
6
        scripts: &[(Uuid, Vec<u8>)],
123
6
    ) -> Result<ScriptRunReport, ServerError> {
124
6
        let mut failures: Vec<ScriptFailure> = Vec::new();
125
8
        for (script_id, bytecode) in scripts {
126
8
            let (input, mut index_table) = serialize_state(&self);
127
8
            match executor.execute(bytecode, &input, Some(DEFAULT_OUTPUT_SIZE)) {
128
6
                Ok(entities) if !entities.is_empty() => {
129
4
                    apply_parsed_entities(&mut self, entities, &mut index_table)?;
130
                }
131
2
                Ok(_) => {}
132
2
                Err(e) => {
133
2
                    failures.push(classify_script_failure(*script_id, &e));
134
2
                }
135
            }
136
        }
137
6
        Ok(ScriptRunReport {
138
6
            state: self,
139
6
            failures,
140
6
        })
141
6
    }
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.
153
pub 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::TaggedTransactions { mut entities, .. }) = tx_result else {
167
        return Ok(None);
168
    };
169
    let Some((FinanceEntity::Transaction(tx), tags, _amount)) = 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.
206
async 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

            
237
10
fn serialize_state(state: &TransactionState) -> (Vec<u8>, IndexTable) {
238
10
    let mut serializer = MemorySerializer::new();
239
10
    let mut index_table = IndexTable::new();
240

            
241
10
    serializer.set_context(ContextType::EntityCreate, EntityType::Transaction);
242

            
243
10
    let is_multi_currency = state
244
10
        .splits
245
10
        .iter()
246
10
        .map(|s| s.commodity_id)
247
10
        .collect::<std::collections::HashSet<_>>()
248
10
        .len()
249
        > 1;
250

            
251
10
    let tx_idx = serializer.add_transaction_from(scripting::TransactionFromArgs {
252
10
        transaction: &state.transaction,
253
10
        is_primary: true,
254
10
        split_count: state.splits.len() as u32,
255
10
        tag_count: state.transaction_tags.len() as u32,
256
10
        is_multi_currency,
257
10
    });
258
10
    serializer.set_primary(tx_idx);
259
10
    index_table.insert(tx_idx, (EntityType::Transaction, state.transaction.id));
260

            
261
10
    let mut split_indices: Vec<(Uuid, u32)> = Vec::new();
262

            
263
15
    for split in &state.splits {
264
15
        let account_name = state
265
15
            .account_names
266
15
            .get(&split.account_id)
267
15
            .map_or("", String::as_str);
268
15
        let split_idx = serializer.add_split_from(split, tx_idx as i32, account_name);
269
15
        split_indices.push((split.id, split_idx));
270
15
        index_table.insert(split_idx, (EntityType::Split, split.id));
271
15
    }
272

            
273
10
    for tag in &state.transaction_tags {
274
7
        serializer.add_tag(
275
7
            *tag.id.as_bytes(),
276
7
            tx_idx as i32,
277
7
            false,
278
7
            false,
279
7
            &tag.tag_name,
280
7
            &tag.tag_value,
281
7
        );
282
7
    }
283

            
284
10
    for (split_id, tag) in &state.split_tags {
285
4
        let parent_idx = split_indices
286
4
            .iter()
287
4
            .find(|(id, _)| id == split_id)
288
4
            .map_or(-1, |(_, idx)| *idx as i32);
289

            
290
4
        serializer.add_tag(
291
4
            *tag.id.as_bytes(),
292
4
            parent_idx,
293
            false,
294
            false,
295
4
            &tag.tag_name,
296
4
            &tag.tag_value,
297
        );
298
    }
299

            
300
10
    (serializer.finalize(DEFAULT_OUTPUT_SIZE), index_table)
301
10
}
302

            
303
6
fn apply_parsed_entities(
304
6
    state: &mut TransactionState,
305
6
    entities: Vec<ParsedEntity>,
306
6
    index_table: &mut IndexTable,
307
6
) -> Result<(), ServerError> {
308
6
    let mut current_output_idx = index_table.len() as u32;
309

            
310
8
    for entity in entities {
311
8
        let entity_id = Uuid::from_bytes(entity.id);
312

            
313
8
        match (entity.entity_type, entity.operation) {
314
            (EntityType::Tag, Operation::Create) => {
315
8
                if let EntityData::Tag { name, value } = entity.data {
316
8
                    let tag_id = Uuid::new_v4();
317
8
                    let tag = Tag {
318
8
                        id: tag_id,
319
8
                        tag_name: name.clone(),
320
8
                        tag_value: value.clone(),
321
8
                        description: None,
322
8
                    };
323

            
324
8
                    match index_table.get(&(entity.parent_idx as u32)) {
325
3
                        Some(&(EntityType::Transaction, tx_id)) => {
326
3
                            log::debug!(
327
                                "script: create tag \"{name}\"=\"{value}\" on transaction {tx_id}"
328
                            );
329
3
                            state.transaction_tags.push(tag);
330
                        }
331
5
                        Some(&(EntityType::Split, split_id)) => {
332
5
                            log::debug!(
333
                                "script: create tag \"{name}\"=\"{value}\" on split {split_id}"
334
                            );
335
5
                            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
8
                    index_table.insert(current_output_idx, (EntityType::Tag, tag_id));
346
8
                    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
6
    Ok(())
452
6
}
453

            
454
1
fn millis_to_datetime(millis: i64) -> DateTime<Utc> {
455
1
    Utc.timestamp_millis_opt(millis)
456
1
        .single()
457
1
        .unwrap_or_default()
458
1
}
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.
468
4
fn classify_script_failure(script_id: Uuid, err: &HookError) -> ScriptFailure {
469
4
    let (code, message) = match err {
470
        HookError::WASM(wasm_err) => err_code_and_message(&classify_runtime_error(wasm_err)),
471
3
        HookError::Engine(engine_err) => err_code_and_message(engine_err),
472
1
        other => ("runtime".to_string(), format!("{other}")),
473
    };
474
4
    ScriptFailure {
475
4
        script_id,
476
4
        code,
477
4
        message,
478
4
    }
479
4
}
480

            
481
#[cfg(test)]
482
mod tests {
483
    use super::*;
484
    use finance::transaction::TransactionBuilder;
485
    use sqlx::types::chrono::Local;
486

            
487
    #[test]
488
1
    fn test_transaction_state_new() {
489
1
        let tx = TransactionBuilder::new()
490
1
            .id(Uuid::new_v4())
491
1
            .post_date(Local::now().into())
492
1
            .enter_date(Local::now().into())
493
1
            .build()
494
1
            .unwrap();
495

            
496
1
        let state = TransactionState::new(tx);
497
1
        assert!(state.splits.is_empty());
498
1
        assert!(state.transaction_tags.is_empty());
499
1
        assert!(state.split_tags.is_empty());
500
1
        assert!(state.prices.is_empty());
501
1
    }
502

            
503
    #[test]
504
1
    fn test_serialize_empty_state() {
505
1
        let tx = TransactionBuilder::new()
506
1
            .id(Uuid::new_v4())
507
1
            .post_date(Local::now().into())
508
1
            .enter_date(Local::now().into())
509
1
            .build()
510
1
            .unwrap();
511

            
512
1
        let state = TransactionState::new(tx);
513
1
        let (bytes, index_table) = serialize_state(&state);
514
1
        assert!(!bytes.is_empty());
515
1
        assert!(
516
1
            index_table.contains_key(&0),
517
            "transaction missing at index 0"
518
        );
519
1
    }
520

            
521
    #[test]
522
1
    fn test_apply_tag_to_transaction() {
523
1
        let tx_id = Uuid::new_v4();
524
1
        let tx = TransactionBuilder::new()
525
1
            .id(tx_id)
526
1
            .post_date(Local::now().into())
527
1
            .enter_date(Local::now().into())
528
1
            .build()
529
1
            .unwrap();
530

            
531
1
        let mut state = TransactionState::new(tx);
532
1
        let mut index_table = IndexTable::new();
533
1
        index_table.insert(0, (EntityType::Transaction, tx_id));
534

            
535
1
        let tag_entity = ParsedEntity {
536
1
            entity_type: EntityType::Tag,
537
1
            operation: Operation::Create,
538
1
            flags: 0,
539
1
            id: *Uuid::new_v4().as_bytes(),
540
1
            parent_idx: 0, // Points to transaction at index 0
541
1
            data: EntityData::Tag {
542
1
                name: "category".to_string(),
543
1
                value: "groceries".to_string(),
544
1
            },
545
1
        };
546

            
547
1
        apply_parsed_entities(&mut state, vec![tag_entity], &mut index_table).unwrap();
548
1
        assert_eq!(state.transaction_tags.len(), 1);
549
1
        assert_eq!(state.transaction_tags[0].tag_name, "category");
550
1
        assert_eq!(state.transaction_tags[0].tag_value, "groceries");
551
1
    }
552

            
553
    #[test]
554
1
    fn test_apply_tag_to_split() {
555
1
        let tx_id = Uuid::new_v4();
556
1
        let split_id = Uuid::new_v4();
557
1
        let tx = TransactionBuilder::new()
558
1
            .id(tx_id)
559
1
            .post_date(Local::now().into())
560
1
            .enter_date(Local::now().into())
561
1
            .build()
562
1
            .unwrap();
563

            
564
1
        let split = Split {
565
1
            id: split_id,
566
1
            tx_id,
567
1
            account_id: Uuid::new_v4(),
568
1
            commodity_id: Uuid::new_v4(),
569
1
            value_num: 100,
570
1
            value_denom: 1,
571
1
            reconcile_state: None,
572
1
            reconcile_date: None,
573
1
            lot_id: None,
574
1
        };
575

            
576
1
        let mut state = TransactionState::new(tx).with(vec![FinanceEntity::Split(split)]);
577
1
        let mut index_table = IndexTable::new();
578
1
        index_table.insert(0, (EntityType::Transaction, tx_id));
579
1
        index_table.insert(1, (EntityType::Split, split_id));
580

            
581
1
        let tag_entity = ParsedEntity {
582
1
            entity_type: EntityType::Tag,
583
1
            operation: Operation::Create,
584
1
            flags: 0,
585
1
            id: *Uuid::new_v4().as_bytes(),
586
1
            parent_idx: 1, // Points to split at index 1
587
1
            data: EntityData::Tag {
588
1
                name: "category".to_string(),
589
1
                value: "groceries".to_string(),
590
1
            },
591
1
        };
592

            
593
1
        apply_parsed_entities(&mut state, vec![tag_entity], &mut index_table).unwrap();
594
1
        assert_eq!(state.split_tags.len(), 1);
595
1
        assert_eq!(state.split_tags[0].0, split_id);
596
1
        assert_eq!(state.split_tags[0].1.tag_name, "category");
597
1
        assert_eq!(state.split_tags[0].1.tag_value, "groceries");
598
1
    }
599

            
600
    #[test]
601
1
    fn test_serialize_state_with_split_tags() {
602
1
        let tx_id = Uuid::new_v4();
603
1
        let split_id = Uuid::new_v4();
604
1
        let tx = TransactionBuilder::new()
605
1
            .id(tx_id)
606
1
            .post_date(Local::now().into())
607
1
            .enter_date(Local::now().into())
608
1
            .build()
609
1
            .unwrap();
610

            
611
1
        let split = Split {
612
1
            id: split_id,
613
1
            tx_id,
614
1
            account_id: Uuid::new_v4(),
615
1
            commodity_id: Uuid::new_v4(),
616
1
            value_num: 100,
617
1
            value_denom: 1,
618
1
            reconcile_state: None,
619
1
            reconcile_date: None,
620
1
            lot_id: None,
621
1
        };
622

            
623
1
        let tag = Tag {
624
1
            id: Uuid::new_v4(),
625
1
            tag_name: "category".to_string(),
626
1
            tag_value: "food".to_string(),
627
1
            description: None,
628
1
        };
629

            
630
1
        let state = TransactionState::new(tx)
631
1
            .with(vec![FinanceEntity::Split(split)])
632
1
            .with_split_tags(vec![(split_id, tag)]);
633

            
634
1
        assert_eq!(state.split_tags.len(), 1);
635
1
        assert_eq!(state.split_tags[0].0, split_id);
636

            
637
1
        let (bytes, index_table) = serialize_state(&state);
638
1
        assert!(!bytes.is_empty());
639
1
        assert_eq!(index_table.len(), 2); // transaction + split
640
1
        assert_eq!(index_table.get(&1).unwrap(), &(EntityType::Split, split_id));
641
1
    }
642

            
643
    #[test]
644
1
    fn test_millis_to_datetime() {
645
1
        let dt = millis_to_datetime(1704067200000);
646
1
        assert_eq!(dt.timestamp(), 1704067200);
647
1
    }
648

            
649
    #[test]
650
1
    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
1
        let tx_id = Uuid::new_v4();
654
1
        let split1_id = Uuid::new_v4();
655
1
        let split2_id = Uuid::new_v4();
656
1
        let commodity_id = Uuid::new_v4();
657

            
658
1
        let tx = TransactionBuilder::new()
659
1
            .id(tx_id)
660
1
            .post_date(Local::now().into())
661
1
            .enter_date(Local::now().into())
662
1
            .build()
663
1
            .unwrap();
664

            
665
1
        let split1 = Split {
666
1
            id: split1_id,
667
1
            tx_id,
668
1
            account_id: Uuid::new_v4(),
669
1
            commodity_id,
670
1
            value_num: -5000,
671
1
            value_denom: 100,
672
1
            reconcile_state: None,
673
1
            reconcile_date: None,
674
1
            lot_id: None,
675
1
        };
676

            
677
1
        let split2 = Split {
678
1
            id: split2_id,
679
1
            tx_id,
680
1
            account_id: Uuid::new_v4(),
681
1
            commodity_id,
682
1
            value_num: 5000,
683
1
            value_denom: 100,
684
1
            reconcile_state: None,
685
1
            reconcile_date: None,
686
1
            lot_id: None,
687
1
        };
688

            
689
1
        let category_tag = Tag {
690
1
            id: Uuid::new_v4(),
691
1
            tag_name: "category".to_string(),
692
1
            tag_value: "food".to_string(),
693
1
            description: None,
694
1
        };
695

            
696
1
        let state = TransactionState::new(tx)
697
1
            .with(vec![
698
1
                FinanceEntity::Split(split1),
699
1
                FinanceEntity::Split(split2),
700
            ])
701
1
            .with_note(Some("groceries".to_string()))
702
1
            .with_split_tags(vec![(split1_id, category_tag)]);
703

            
704
1
        assert_eq!(state.transaction_tags.len(), 1);
705
1
        assert_eq!(state.split_tags.len(), 1);
706

            
707
1
        let script_id = Uuid::new_v4();
708
1
        let executor = ScriptExecutor::try_new().expect("baseline engine");
709
1
        let report = state
710
1
            .run_scripts(&executor, &[(script_id, TAG_SYNC_WASM.to_vec())])
711
1
            .expect("run_scripts failed");
712
1
        assert!(
713
1
            report.failures.is_empty(),
714
            "tag_sync.wasm must run cleanly: {:?}",
715
            report.failures
716
        );
717

            
718
1
        let new_tx_tags: Vec<_> = report
719
1
            .state
720
1
            .transaction_tags
721
1
            .iter()
722
2
            .filter(|t| t.tag_name != "note")
723
1
            .collect();
724
1
        assert_eq!(
725
1
            new_tx_tags.len(),
726
            1,
727
            "tag_sync should copy category tag from split to transaction"
728
        );
729
1
        assert_eq!(new_tx_tags[0].tag_name, "category");
730
1
        assert_eq!(new_tx_tags[0].tag_value, "food");
731
1
    }
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
1
    fn run_scripts_captures_failed_script_into_structured_report() {
738
1
        let tx_id = Uuid::new_v4();
739
1
        let tx = TransactionBuilder::new()
740
1
            .id(tx_id)
741
1
            .post_date(Local::now().into())
742
1
            .enter_date(Local::now().into())
743
1
            .build()
744
1
            .unwrap();
745
1
        let state = TransactionState::new(tx);
746

            
747
1
        let script_id = Uuid::new_v4();
748
1
        let invalid_bytecode: Vec<u8> = vec![0xde, 0xad, 0xbe, 0xef];
749
1
        let executor = ScriptExecutor::try_new().expect("baseline engine");
750
1
        let report = state
751
1
            .run_scripts(&executor, &[(script_id, invalid_bytecode)])
752
1
            .expect("run_scripts must surface bad-bytecode as a failure cell, not an outer Err");
753

            
754
1
        assert_eq!(
755
1
            report.failures.len(),
756
            1,
757
            "single bad script should produce exactly one captured failure"
758
        );
759
1
        assert_eq!(report.failures[0].script_id, script_id);
760
1
        assert!(
761
1
            !report.failures[0].code.is_empty(),
762
            "captured failure must carry a non-empty code symbol"
763
        );
764
1
        assert!(
765
1
            report.state.transaction_tags.is_empty(),
766
            "failed script must not have mutated state"
767
        );
768
1
        assert_eq!(report.state.transaction.id, tx_id);
769
1
    }
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
1
    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
1
        let tx_id = Uuid::new_v4();
783
1
        let split1_id = Uuid::new_v4();
784
1
        let split2_id = Uuid::new_v4();
785
1
        let commodity_id = Uuid::new_v4();
786
1
        let tx = TransactionBuilder::new()
787
1
            .id(tx_id)
788
1
            .post_date(Local::now().into())
789
1
            .enter_date(Local::now().into())
790
1
            .build()
791
1
            .unwrap();
792
1
        let split1 = Split {
793
1
            id: split1_id,
794
1
            tx_id,
795
1
            account_id: Uuid::new_v4(),
796
1
            commodity_id,
797
1
            value_num: -5000,
798
1
            value_denom: 100,
799
1
            reconcile_state: None,
800
1
            reconcile_date: None,
801
1
            lot_id: None,
802
1
        };
803
1
        let split2 = Split {
804
1
            id: split2_id,
805
1
            tx_id,
806
1
            account_id: Uuid::new_v4(),
807
1
            commodity_id,
808
1
            value_num: 5000,
809
1
            value_denom: 100,
810
1
            reconcile_state: None,
811
1
            reconcile_date: None,
812
1
            lot_id: None,
813
1
        };
814
1
        let category_tag = Tag {
815
1
            id: Uuid::new_v4(),
816
1
            tag_name: "category".to_string(),
817
1
            tag_value: "food".to_string(),
818
1
            description: None,
819
1
        };
820
1
        let state = TransactionState::new(tx)
821
1
            .with(vec![
822
1
                FinanceEntity::Split(split1),
823
1
                FinanceEntity::Split(split2),
824
            ])
825
1
            .with_note(Some("groceries".to_string()))
826
1
            .with_split_tags(vec![(split1_id, category_tag)]);
827

            
828
1
        let bad_id = Uuid::new_v4();
829
1
        let good_id = Uuid::new_v4();
830
1
        let executor = ScriptExecutor::try_new().expect("baseline engine");
831
1
        let report = state
832
1
            .run_scripts(
833
1
                &executor,
834
1
                &[
835
1
                    (bad_id, vec![0xde, 0xad, 0xbe, 0xef]),
836
1
                    (good_id, TAG_SYNC_WASM.to_vec()),
837
1
                ],
838
            )
839
1
            .expect("run_scripts must capture per-script failures structurally");
840

            
841
1
        assert_eq!(report.failures.len(), 1);
842
1
        assert_eq!(report.failures[0].script_id, bad_id);
843

            
844
1
        let category_tx_tags: Vec<_> = report
845
1
            .state
846
1
            .transaction_tags
847
1
            .iter()
848
2
            .filter(|t| t.tag_name == "category")
849
1
            .collect();
850
1
        assert_eq!(
851
1
            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
1
    }
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
1
    fn classify_script_failure_maps_engine_error_to_symbol_code() {
866
        use scripting::runtime::EngineError;
867
1
        let engine_err = EngineError::ScriptRaised {
868
1
            code: "COMMODITY-MISMATCH".to_string(),
869
1
            message: "USD vs EUR".to_string(),
870
1
        };
871
1
        let hook_err = HookError::Engine(engine_err);
872
1
        let id = Uuid::new_v4();
873
1
        let failure = classify_script_failure(id, &hook_err);
874
1
        assert_eq!(failure.script_id, id);
875
1
        assert_eq!(failure.code, "COMMODITY-MISMATCH");
876
1
        assert_eq!(failure.message, "USD vs EUR");
877
1
    }
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
1
    fn classify_script_failure_falls_back_to_runtime_for_non_engine_error() {
885
1
        let hook_err = HookError::Script("synthetic test failure".to_string());
886
1
        let id = Uuid::new_v4();
887
1
        let failure = classify_script_failure(id, &hook_err);
888
1
        assert_eq!(failure.script_id, id);
889
1
        assert_eq!(failure.code, "runtime");
890
1
        assert!(failure.message.contains("synthetic test failure"));
891
1
    }
892
}