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::{
6    ContextType, EntityData, EntityType, MemorySerializer, Operation, ParsedEntity, ScriptExecutor,
7};
8use sqlx::types::Uuid;
9use sqlx::types::chrono::{DateTime, TimeZone, Utc};
10use std::collections::HashMap;
11
12use crate::command::FinanceEntity;
13use crate::error::ServerError;
14
15const DEFAULT_OUTPUT_SIZE: u32 = 64 * 1024;
16
17type IndexTable = HashMap<u32, (EntityType, Uuid)>;
18
19pub struct TransactionState {
20    pub transaction: Transaction,
21    pub splits: Vec<Split>,
22    pub transaction_tags: Vec<Tag>,
23    pub split_tags: Vec<(Uuid, Tag)>,
24    pub prices: Vec<Price>,
25}
26
27impl TransactionState {
28    #[must_use]
29    pub fn new(transaction: Transaction) -> Self {
30        Self {
31            transaction,
32            splits: Vec::new(),
33            transaction_tags: Vec::new(),
34            split_tags: Vec::new(),
35            prices: Vec::new(),
36        }
37    }
38
39    #[must_use]
40    pub fn with(mut self, entities: Vec<FinanceEntity>) -> Self {
41        for entity in entities {
42            match entity {
43                FinanceEntity::Split(s) => self.splits.push(s),
44                FinanceEntity::Price(p) => self.prices.push(p),
45                FinanceEntity::Tag(t) => self.transaction_tags.push(t),
46                _ => {}
47            }
48        }
49        self
50    }
51
52    #[must_use]
53    pub fn with_split_tags(mut self, tags: Vec<(Uuid, Tag)>) -> Self {
54        self.split_tags = tags;
55        self
56    }
57
58    #[must_use]
59    pub fn with_note(mut self, note: Option<String>) -> Self {
60        if let Some(note) = note {
61            self.transaction_tags.push(Tag {
62                id: Uuid::new_v4(),
63                tag_name: "note".to_string(),
64                tag_value: note,
65                description: None,
66            });
67        }
68        self
69    }
70
71    pub fn run_scripts(
72        mut self,
73        executor: &ScriptExecutor,
74        scripts: &[(Uuid, Vec<u8>)],
75    ) -> Result<Self, ServerError> {
76        for (script_id, bytecode) in scripts {
77            let (input, mut index_table) = serialize_state(&self);
78            match executor.execute(bytecode, &input, Some(DEFAULT_OUTPUT_SIZE)) {
79                Ok(entities) if !entities.is_empty() => {
80                    apply_parsed_entities(&mut self, entities, &mut index_table)?;
81                }
82                Ok(_) => {}
83                Err(e) => {
84                    log::error!(
85                        "{}",
86                        t!("Script %{id} failed, skipping: %{err}", id = script_id, err = e : {:?})
87                    );
88                }
89            }
90        }
91        Ok(self)
92    }
93}
94
95fn serialize_state(state: &TransactionState) -> (Vec<u8>, IndexTable) {
96    let mut serializer = MemorySerializer::new();
97    let mut index_table = IndexTable::new();
98
99    serializer.set_context(ContextType::EntityCreate, EntityType::Transaction);
100
101    let is_multi_currency = state
102        .splits
103        .iter()
104        .map(|s| s.commodity_id)
105        .collect::<std::collections::HashSet<_>>()
106        .len()
107        > 1;
108
109    let tx_idx = serializer.add_transaction_from(
110        &state.transaction,
111        true,
112        state.splits.len() as u32,
113        state.transaction_tags.len() as u32,
114        is_multi_currency,
115    );
116    serializer.set_primary(tx_idx);
117    index_table.insert(tx_idx, (EntityType::Transaction, state.transaction.id));
118
119    let mut split_indices: Vec<(Uuid, u32)> = Vec::new();
120
121    for split in &state.splits {
122        let split_idx = serializer.add_split_from(split, tx_idx as i32);
123        split_indices.push((split.id, split_idx));
124        index_table.insert(split_idx, (EntityType::Split, split.id));
125    }
126
127    for tag in &state.transaction_tags {
128        serializer.add_tag(
129            *tag.id.as_bytes(),
130            tx_idx as i32,
131            false,
132            false,
133            &tag.tag_name,
134            &tag.tag_value,
135        );
136    }
137
138    for (split_id, tag) in &state.split_tags {
139        let parent_idx = split_indices
140            .iter()
141            .find(|(id, _)| id == split_id)
142            .map_or(-1, |(_, idx)| *idx as i32);
143
144        serializer.add_tag(
145            *tag.id.as_bytes(),
146            parent_idx,
147            false,
148            false,
149            &tag.tag_name,
150            &tag.tag_value,
151        );
152    }
153
154    (serializer.finalize(DEFAULT_OUTPUT_SIZE), index_table)
155}
156
157fn apply_parsed_entities(
158    state: &mut TransactionState,
159    entities: Vec<ParsedEntity>,
160    index_table: &mut IndexTable,
161) -> Result<(), ServerError> {
162    let mut current_output_idx = index_table.len() as u32;
163
164    for entity in entities {
165        let entity_id = Uuid::from_bytes(entity.id);
166
167        match (entity.entity_type, entity.operation) {
168            (EntityType::Tag, Operation::Create) => {
169                if let EntityData::Tag { name, value } = entity.data {
170                    let tag_id = Uuid::new_v4();
171                    let tag = Tag {
172                        id: tag_id,
173                        tag_name: name.clone(),
174                        tag_value: value.clone(),
175                        description: None,
176                    };
177
178                    match index_table.get(&(entity.parent_idx as u32)) {
179                        Some(&(EntityType::Transaction, tx_id)) => {
180                            log::debug!(
181                                "script: create tag \"{name}\"=\"{value}\" on transaction {tx_id}"
182                            );
183                            state.transaction_tags.push(tag);
184                        }
185                        Some(&(EntityType::Split, split_id)) => {
186                            log::debug!(
187                                "script: create tag \"{name}\"=\"{value}\" on split {split_id}"
188                            );
189                            state.split_tags.push((split_id, tag));
190                        }
191                        _ => {
192                            log::warn!(
193                                "Tag parent_idx {} not found in index table",
194                                entity.parent_idx
195                            );
196                        }
197                    }
198
199                    index_table.insert(current_output_idx, (EntityType::Tag, tag_id));
200                    current_output_idx += 1;
201                }
202            }
203            (EntityType::Split, Operation::Create) => {
204                if let EntityData::Split {
205                    account_id,
206                    commodity_id,
207                    value_num,
208                    value_denom,
209                    reconcile_state,
210                    reconcile_date,
211                } = entity.data
212                {
213                    let account_id = Uuid::from_bytes(account_id);
214                    let commodity_id = Uuid::from_bytes(commodity_id);
215                    let split_id = Uuid::new_v4();
216                    log::debug!(
217                        "script: create split {split_id} account={account_id} value={value_num}/{value_denom}"
218                    );
219                    let split = Split {
220                        id: split_id,
221                        tx_id: state.transaction.id,
222                        account_id,
223                        commodity_id,
224                        value_num,
225                        value_denom,
226                        reconcile_state: if reconcile_state == 0 {
227                            None
228                        } else {
229                            Some(reconcile_state != 0)
230                        },
231                        reconcile_date: if reconcile_date == 0 {
232                            None
233                        } else {
234                            Some(
235                                Utc.timestamp_millis_opt(reconcile_date)
236                                    .single()
237                                    .unwrap_or_default(),
238                            )
239                        },
240                        lot_id: None,
241                    };
242                    state.splits.push(split);
243
244                    index_table.insert(current_output_idx, (EntityType::Split, split_id));
245                    current_output_idx += 1;
246                }
247            }
248            (EntityType::Split, Operation::Update) => {
249                if let EntityData::Split {
250                    account_id,
251                    commodity_id,
252                    value_num,
253                    value_denom,
254                    reconcile_state,
255                    reconcile_date,
256                } = entity.data
257                    && let Some(split) = state.splits.iter_mut().find(|s| s.id == entity_id)
258                {
259                    log::debug!("script: update split {entity_id} value={value_num}/{value_denom}");
260                    split.account_id = Uuid::from_bytes(account_id);
261                    split.commodity_id = Uuid::from_bytes(commodity_id);
262                    split.value_num = value_num;
263                    split.value_denom = value_denom;
264                    split.reconcile_state = if reconcile_state == 0 {
265                        None
266                    } else {
267                        Some(reconcile_state != 0)
268                    };
269                    split.reconcile_date = if reconcile_date == 0 {
270                        None
271                    } else {
272                        Some(
273                            Utc.timestamp_millis_opt(reconcile_date)
274                                .single()
275                                .unwrap_or_default(),
276                        )
277                    };
278                }
279            }
280            (EntityType::Transaction, Operation::Update) => {
281                if let EntityData::Transaction {
282                    post_date,
283                    enter_date,
284                    ..
285                } = entity.data
286                {
287                    log::debug!("script: update transaction {entity_id}");
288                    state.transaction.post_date = millis_to_datetime(post_date);
289                    state.transaction.enter_date = millis_to_datetime(enter_date);
290                }
291            }
292            (EntityType::Split, Operation::Delete) => {
293                log::debug!("script: delete split {entity_id}");
294                state.splits.retain(|s| s.id != entity_id);
295                state.split_tags.retain(|(id, _)| *id != entity_id);
296            }
297            (EntityType::Tag, Operation::Delete) => {
298                log::debug!("script: delete tag {entity_id}");
299                state.transaction_tags.retain(|t| t.id != entity_id);
300                state.split_tags.retain(|(_, t)| t.id != entity_id);
301            }
302            _ => {}
303        }
304    }
305    Ok(())
306}
307
308fn millis_to_datetime(millis: i64) -> DateTime<Utc> {
309    Utc.timestamp_millis_opt(millis)
310        .single()
311        .unwrap_or_default()
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use finance::transaction::TransactionBuilder;
318    use sqlx::types::chrono::Local;
319
320    #[test]
321    fn test_transaction_state_new() {
322        let tx = TransactionBuilder::new()
323            .id(Uuid::new_v4())
324            .post_date(Local::now().into())
325            .enter_date(Local::now().into())
326            .build()
327            .unwrap();
328
329        let state = TransactionState::new(tx);
330        assert!(state.splits.is_empty());
331        assert!(state.transaction_tags.is_empty());
332        assert!(state.split_tags.is_empty());
333        assert!(state.prices.is_empty());
334    }
335
336    #[test]
337    fn test_serialize_empty_state() {
338        let tx = TransactionBuilder::new()
339            .id(Uuid::new_v4())
340            .post_date(Local::now().into())
341            .enter_date(Local::now().into())
342            .build()
343            .unwrap();
344
345        let state = TransactionState::new(tx);
346        let (bytes, index_table) = serialize_state(&state);
347        assert!(!bytes.is_empty());
348        assert!(index_table.get(&0).is_some()); // Transaction at index 0
349    }
350
351    #[test]
352    fn test_apply_tag_to_transaction() {
353        let tx_id = Uuid::new_v4();
354        let tx = TransactionBuilder::new()
355            .id(tx_id)
356            .post_date(Local::now().into())
357            .enter_date(Local::now().into())
358            .build()
359            .unwrap();
360
361        let mut state = TransactionState::new(tx);
362        let mut index_table = IndexTable::new();
363        index_table.insert(0, (EntityType::Transaction, tx_id));
364
365        let tag_entity = ParsedEntity {
366            entity_type: EntityType::Tag,
367            operation: Operation::Create,
368            flags: 0,
369            id: *Uuid::new_v4().as_bytes(),
370            parent_idx: 0, // Points to transaction at index 0
371            data: EntityData::Tag {
372                name: "category".to_string(),
373                value: "groceries".to_string(),
374            },
375        };
376
377        apply_parsed_entities(&mut state, vec![tag_entity], &mut index_table).unwrap();
378        assert_eq!(state.transaction_tags.len(), 1);
379        assert_eq!(state.transaction_tags[0].tag_name, "category");
380        assert_eq!(state.transaction_tags[0].tag_value, "groceries");
381    }
382
383    #[test]
384    fn test_apply_tag_to_split() {
385        let tx_id = Uuid::new_v4();
386        let split_id = Uuid::new_v4();
387        let tx = TransactionBuilder::new()
388            .id(tx_id)
389            .post_date(Local::now().into())
390            .enter_date(Local::now().into())
391            .build()
392            .unwrap();
393
394        let split = Split {
395            id: split_id,
396            tx_id,
397            account_id: Uuid::new_v4(),
398            commodity_id: Uuid::new_v4(),
399            value_num: 100,
400            value_denom: 1,
401            reconcile_state: None,
402            reconcile_date: None,
403            lot_id: None,
404        };
405
406        let mut state = TransactionState::new(tx).with(vec![FinanceEntity::Split(split)]);
407        let mut index_table = IndexTable::new();
408        index_table.insert(0, (EntityType::Transaction, tx_id));
409        index_table.insert(1, (EntityType::Split, split_id));
410
411        let tag_entity = ParsedEntity {
412            entity_type: EntityType::Tag,
413            operation: Operation::Create,
414            flags: 0,
415            id: *Uuid::new_v4().as_bytes(),
416            parent_idx: 1, // Points to split at index 1
417            data: EntityData::Tag {
418                name: "category".to_string(),
419                value: "groceries".to_string(),
420            },
421        };
422
423        apply_parsed_entities(&mut state, vec![tag_entity], &mut index_table).unwrap();
424        assert_eq!(state.split_tags.len(), 1);
425        assert_eq!(state.split_tags[0].0, split_id);
426        assert_eq!(state.split_tags[0].1.tag_name, "category");
427        assert_eq!(state.split_tags[0].1.tag_value, "groceries");
428    }
429
430    #[test]
431    fn test_serialize_state_with_split_tags() {
432        let tx_id = Uuid::new_v4();
433        let split_id = Uuid::new_v4();
434        let tx = TransactionBuilder::new()
435            .id(tx_id)
436            .post_date(Local::now().into())
437            .enter_date(Local::now().into())
438            .build()
439            .unwrap();
440
441        let split = Split {
442            id: split_id,
443            tx_id,
444            account_id: Uuid::new_v4(),
445            commodity_id: Uuid::new_v4(),
446            value_num: 100,
447            value_denom: 1,
448            reconcile_state: None,
449            reconcile_date: None,
450            lot_id: None,
451        };
452
453        let tag = Tag {
454            id: Uuid::new_v4(),
455            tag_name: "category".to_string(),
456            tag_value: "food".to_string(),
457            description: None,
458        };
459
460        let state = TransactionState::new(tx)
461            .with(vec![FinanceEntity::Split(split)])
462            .with_split_tags(vec![(split_id, tag)]);
463
464        assert_eq!(state.split_tags.len(), 1);
465        assert_eq!(state.split_tags[0].0, split_id);
466
467        let (bytes, index_table) = serialize_state(&state);
468        assert!(!bytes.is_empty());
469        assert_eq!(index_table.len(), 2); // transaction + split
470        assert_eq!(index_table.get(&1).unwrap(), &(EntityType::Split, split_id));
471    }
472
473    #[test]
474    fn test_millis_to_datetime() {
475        let dt = millis_to_datetime(1704067200000);
476        assert_eq!(dt.timestamp(), 1704067200);
477    }
478
479    #[test]
480    fn test_tag_sync_copies_split_tags_to_transaction() {
481        const TAG_SYNC_WASM: &[u8] = include_bytes!("../../web/static/wasm/tag_sync.wasm");
482
483        let tx_id = Uuid::new_v4();
484        let split1_id = Uuid::new_v4();
485        let split2_id = Uuid::new_v4();
486        let commodity_id = Uuid::new_v4();
487
488        let tx = TransactionBuilder::new()
489            .id(tx_id)
490            .post_date(Local::now().into())
491            .enter_date(Local::now().into())
492            .build()
493            .unwrap();
494
495        let split1 = Split {
496            id: split1_id,
497            tx_id,
498            account_id: Uuid::new_v4(),
499            commodity_id,
500            value_num: -5000,
501            value_denom: 100,
502            reconcile_state: None,
503            reconcile_date: None,
504            lot_id: None,
505        };
506
507        let split2 = Split {
508            id: split2_id,
509            tx_id,
510            account_id: Uuid::new_v4(),
511            commodity_id,
512            value_num: 5000,
513            value_denom: 100,
514            reconcile_state: None,
515            reconcile_date: None,
516            lot_id: None,
517        };
518
519        let category_tag = Tag {
520            id: Uuid::new_v4(),
521            tag_name: "category".to_string(),
522            tag_value: "food".to_string(),
523            description: None,
524        };
525
526        let state = TransactionState::new(tx)
527            .with(vec![
528                FinanceEntity::Split(split1),
529                FinanceEntity::Split(split2),
530            ])
531            .with_note(Some("groceries".to_string()))
532            .with_split_tags(vec![(split1_id, category_tag)]);
533
534        assert_eq!(state.transaction_tags.len(), 1);
535        assert_eq!(state.split_tags.len(), 1);
536
537        let script_id = Uuid::new_v4();
538        let executor = ScriptExecutor::new();
539        let state = state
540            .run_scripts(&executor, &[(script_id, TAG_SYNC_WASM.to_vec())])
541            .expect("run_scripts failed");
542
543        let new_tx_tags: Vec<_> = state
544            .transaction_tags
545            .iter()
546            .filter(|t| t.tag_name != "note")
547            .collect();
548        assert_eq!(
549            new_tx_tags.len(),
550            1,
551            "tag_sync should copy category tag from split to transaction"
552        );
553        assert_eq!(new_tx_tags[0].tag_name, "category");
554        assert_eq!(new_tx_tags[0].tag_value, "food");
555    }
556}