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()); }
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, 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, 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); 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}