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

            
12
use crate::command::FinanceEntity;
13
use crate::error::ServerError;
14

            
15
const DEFAULT_OUTPUT_SIZE: u32 = 64 * 1024;
16

            
17
type IndexTable = HashMap<u32, (EntityType, Uuid)>;
18

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

            
27
impl TransactionState {
28
    #[must_use]
29
160
    pub fn new(transaction: Transaction) -> Self {
30
160
        Self {
31
160
            transaction,
32
160
            splits: Vec::new(),
33
160
            transaction_tags: Vec::new(),
34
160
            split_tags: Vec::new(),
35
160
            prices: Vec::new(),
36
160
        }
37
160
    }
38

            
39
    #[must_use]
40
311
    pub fn with(mut self, entities: Vec<FinanceEntity>) -> Self {
41
316
        for entity in entities {
42
316
            match entity {
43
315
                FinanceEntity::Split(s) => self.splits.push(s),
44
1
                FinanceEntity::Price(p) => self.prices.push(p),
45
                FinanceEntity::Tag(t) => self.transaction_tags.push(t),
46
                _ => {}
47
            }
48
        }
49
311
        self
50
311
    }
51

            
52
    #[must_use]
53
156
    pub fn with_split_tags(mut self, tags: Vec<(Uuid, Tag)>) -> Self {
54
156
        self.split_tags = tags;
55
156
        self
56
156
    }
57

            
58
    #[must_use]
59
155
    pub fn with_note(mut self, note: Option<String>) -> Self {
60
155
        if let Some(note) = note {
61
16
            self.transaction_tags.push(Tag {
62
16
                id: Uuid::new_v4(),
63
16
                tag_name: "note".to_string(),
64
16
                tag_value: note,
65
16
                description: None,
66
16
            });
67
139
        }
68
155
        self
69
155
    }
70

            
71
4
    pub fn run_scripts(
72
4
        mut self,
73
4
        executor: &ScriptExecutor,
74
4
        scripts: &[(Uuid, Vec<u8>)],
75
4
    ) -> Result<Self, ServerError> {
76
5
        for (script_id, bytecode) in scripts {
77
5
            let (input, mut index_table) = serialize_state(&self);
78
5
            match executor.execute(bytecode, &input, Some(DEFAULT_OUTPUT_SIZE)) {
79
5
                Ok(entities) if !entities.is_empty() => {
80
4
                    apply_parsed_entities(&mut self, entities, &mut index_table)?;
81
                }
82
1
                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
4
        Ok(self)
92
4
    }
93
}
94

            
95
7
fn serialize_state(state: &TransactionState) -> (Vec<u8>, IndexTable) {
96
7
    let mut serializer = MemorySerializer::new();
97
7
    let mut index_table = IndexTable::new();
98

            
99
7
    serializer.set_context(ContextType::EntityCreate, EntityType::Transaction);
100

            
101
7
    let is_multi_currency = state
102
7
        .splits
103
7
        .iter()
104
7
        .map(|s| s.commodity_id)
105
7
        .collect::<std::collections::HashSet<_>>()
106
7
        .len()
107
        > 1;
108

            
109
7
    let tx_idx = serializer.add_transaction_from(
110
7
        &state.transaction,
111
        true,
112
7
        state.splits.len() as u32,
113
7
        state.transaction_tags.len() as u32,
114
7
        is_multi_currency,
115
    );
116
7
    serializer.set_primary(tx_idx);
117
7
    index_table.insert(tx_idx, (EntityType::Transaction, state.transaction.id));
118

            
119
7
    let mut split_indices: Vec<(Uuid, u32)> = Vec::new();
120

            
121
11
    for split in &state.splits {
122
11
        let split_idx = serializer.add_split_from(split, tx_idx as i32);
123
11
        split_indices.push((split.id, split_idx));
124
11
        index_table.insert(split_idx, (EntityType::Split, split.id));
125
11
    }
126

            
127
7
    for tag in &state.transaction_tags {
128
5
        serializer.add_tag(
129
5
            *tag.id.as_bytes(),
130
5
            tx_idx as i32,
131
5
            false,
132
5
            false,
133
5
            &tag.tag_name,
134
5
            &tag.tag_value,
135
5
        );
136
5
    }
137

            
138
7
    for (split_id, tag) in &state.split_tags {
139
4
        let parent_idx = split_indices
140
4
            .iter()
141
5
            .find(|(id, _)| id == split_id)
142
4
            .map_or(-1, |(_, idx)| *idx as i32);
143

            
144
4
        serializer.add_tag(
145
4
            *tag.id.as_bytes(),
146
4
            parent_idx,
147
            false,
148
            false,
149
4
            &tag.tag_name,
150
4
            &tag.tag_value,
151
        );
152
    }
153

            
154
7
    (serializer.finalize(DEFAULT_OUTPUT_SIZE), index_table)
155
7
}
156

            
157
6
fn apply_parsed_entities(
158
6
    state: &mut TransactionState,
159
6
    entities: Vec<ParsedEntity>,
160
6
    index_table: &mut IndexTable,
161
6
) -> Result<(), ServerError> {
162
6
    let mut current_output_idx = index_table.len() as u32;
163

            
164
8
    for entity in entities {
165
8
        let entity_id = Uuid::from_bytes(entity.id);
166

            
167
8
        match (entity.entity_type, entity.operation) {
168
            (EntityType::Tag, Operation::Create) => {
169
8
                if let EntityData::Tag { name, value } = entity.data {
170
8
                    let tag_id = Uuid::new_v4();
171
8
                    let tag = Tag {
172
8
                        id: tag_id,
173
8
                        tag_name: name.clone(),
174
8
                        tag_value: value.clone(),
175
8
                        description: None,
176
8
                    };
177

            
178
8
                    match index_table.get(&(entity.parent_idx as u32)) {
179
3
                        Some(&(EntityType::Transaction, tx_id)) => {
180
3
                            log::debug!(
181
                                "script: create tag \"{name}\"=\"{value}\" on transaction {tx_id}"
182
                            );
183
3
                            state.transaction_tags.push(tag);
184
                        }
185
5
                        Some(&(EntityType::Split, split_id)) => {
186
5
                            log::debug!(
187
                                "script: create tag \"{name}\"=\"{value}\" on split {split_id}"
188
                            );
189
5
                            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
8
                    index_table.insert(current_output_idx, (EntityType::Tag, tag_id));
200
8
                    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
6
    Ok(())
306
6
}
307

            
308
1
fn millis_to_datetime(millis: i64) -> DateTime<Utc> {
309
1
    Utc.timestamp_millis_opt(millis)
310
1
        .single()
311
1
        .unwrap_or_default()
312
1
}
313

            
314
#[cfg(test)]
315
mod tests {
316
    use super::*;
317
    use finance::transaction::TransactionBuilder;
318
    use sqlx::types::chrono::Local;
319

            
320
    #[test]
321
1
    fn test_transaction_state_new() {
322
1
        let tx = TransactionBuilder::new()
323
1
            .id(Uuid::new_v4())
324
1
            .post_date(Local::now().into())
325
1
            .enter_date(Local::now().into())
326
1
            .build()
327
1
            .unwrap();
328

            
329
1
        let state = TransactionState::new(tx);
330
1
        assert!(state.splits.is_empty());
331
1
        assert!(state.transaction_tags.is_empty());
332
1
        assert!(state.split_tags.is_empty());
333
1
        assert!(state.prices.is_empty());
334
1
    }
335

            
336
    #[test]
337
1
    fn test_serialize_empty_state() {
338
1
        let tx = TransactionBuilder::new()
339
1
            .id(Uuid::new_v4())
340
1
            .post_date(Local::now().into())
341
1
            .enter_date(Local::now().into())
342
1
            .build()
343
1
            .unwrap();
344

            
345
1
        let state = TransactionState::new(tx);
346
1
        let (bytes, index_table) = serialize_state(&state);
347
1
        assert!(!bytes.is_empty());
348
1
        assert!(index_table.get(&0).is_some()); // Transaction at index 0
349
1
    }
350

            
351
    #[test]
352
1
    fn test_apply_tag_to_transaction() {
353
1
        let tx_id = Uuid::new_v4();
354
1
        let tx = TransactionBuilder::new()
355
1
            .id(tx_id)
356
1
            .post_date(Local::now().into())
357
1
            .enter_date(Local::now().into())
358
1
            .build()
359
1
            .unwrap();
360

            
361
1
        let mut state = TransactionState::new(tx);
362
1
        let mut index_table = IndexTable::new();
363
1
        index_table.insert(0, (EntityType::Transaction, tx_id));
364

            
365
1
        let tag_entity = ParsedEntity {
366
1
            entity_type: EntityType::Tag,
367
1
            operation: Operation::Create,
368
1
            flags: 0,
369
1
            id: *Uuid::new_v4().as_bytes(),
370
1
            parent_idx: 0, // Points to transaction at index 0
371
1
            data: EntityData::Tag {
372
1
                name: "category".to_string(),
373
1
                value: "groceries".to_string(),
374
1
            },
375
1
        };
376

            
377
1
        apply_parsed_entities(&mut state, vec![tag_entity], &mut index_table).unwrap();
378
1
        assert_eq!(state.transaction_tags.len(), 1);
379
1
        assert_eq!(state.transaction_tags[0].tag_name, "category");
380
1
        assert_eq!(state.transaction_tags[0].tag_value, "groceries");
381
1
    }
382

            
383
    #[test]
384
1
    fn test_apply_tag_to_split() {
385
1
        let tx_id = Uuid::new_v4();
386
1
        let split_id = Uuid::new_v4();
387
1
        let tx = TransactionBuilder::new()
388
1
            .id(tx_id)
389
1
            .post_date(Local::now().into())
390
1
            .enter_date(Local::now().into())
391
1
            .build()
392
1
            .unwrap();
393

            
394
1
        let split = Split {
395
1
            id: split_id,
396
1
            tx_id,
397
1
            account_id: Uuid::new_v4(),
398
1
            commodity_id: Uuid::new_v4(),
399
1
            value_num: 100,
400
1
            value_denom: 1,
401
1
            reconcile_state: None,
402
1
            reconcile_date: None,
403
1
            lot_id: None,
404
1
        };
405

            
406
1
        let mut state = TransactionState::new(tx).with(vec![FinanceEntity::Split(split)]);
407
1
        let mut index_table = IndexTable::new();
408
1
        index_table.insert(0, (EntityType::Transaction, tx_id));
409
1
        index_table.insert(1, (EntityType::Split, split_id));
410

            
411
1
        let tag_entity = ParsedEntity {
412
1
            entity_type: EntityType::Tag,
413
1
            operation: Operation::Create,
414
1
            flags: 0,
415
1
            id: *Uuid::new_v4().as_bytes(),
416
1
            parent_idx: 1, // Points to split at index 1
417
1
            data: EntityData::Tag {
418
1
                name: "category".to_string(),
419
1
                value: "groceries".to_string(),
420
1
            },
421
1
        };
422

            
423
1
        apply_parsed_entities(&mut state, vec![tag_entity], &mut index_table).unwrap();
424
1
        assert_eq!(state.split_tags.len(), 1);
425
1
        assert_eq!(state.split_tags[0].0, split_id);
426
1
        assert_eq!(state.split_tags[0].1.tag_name, "category");
427
1
        assert_eq!(state.split_tags[0].1.tag_value, "groceries");
428
1
    }
429

            
430
    #[test]
431
1
    fn test_serialize_state_with_split_tags() {
432
1
        let tx_id = Uuid::new_v4();
433
1
        let split_id = Uuid::new_v4();
434
1
        let tx = TransactionBuilder::new()
435
1
            .id(tx_id)
436
1
            .post_date(Local::now().into())
437
1
            .enter_date(Local::now().into())
438
1
            .build()
439
1
            .unwrap();
440

            
441
1
        let split = Split {
442
1
            id: split_id,
443
1
            tx_id,
444
1
            account_id: Uuid::new_v4(),
445
1
            commodity_id: Uuid::new_v4(),
446
1
            value_num: 100,
447
1
            value_denom: 1,
448
1
            reconcile_state: None,
449
1
            reconcile_date: None,
450
1
            lot_id: None,
451
1
        };
452

            
453
1
        let tag = Tag {
454
1
            id: Uuid::new_v4(),
455
1
            tag_name: "category".to_string(),
456
1
            tag_value: "food".to_string(),
457
1
            description: None,
458
1
        };
459

            
460
1
        let state = TransactionState::new(tx)
461
1
            .with(vec![FinanceEntity::Split(split)])
462
1
            .with_split_tags(vec![(split_id, tag)]);
463

            
464
1
        assert_eq!(state.split_tags.len(), 1);
465
1
        assert_eq!(state.split_tags[0].0, split_id);
466

            
467
1
        let (bytes, index_table) = serialize_state(&state);
468
1
        assert!(!bytes.is_empty());
469
1
        assert_eq!(index_table.len(), 2); // transaction + split
470
1
        assert_eq!(index_table.get(&1).unwrap(), &(EntityType::Split, split_id));
471
1
    }
472

            
473
    #[test]
474
1
    fn test_millis_to_datetime() {
475
1
        let dt = millis_to_datetime(1704067200000);
476
1
        assert_eq!(dt.timestamp(), 1704067200);
477
1
    }
478

            
479
    #[test]
480
1
    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
1
        let tx_id = Uuid::new_v4();
484
1
        let split1_id = Uuid::new_v4();
485
1
        let split2_id = Uuid::new_v4();
486
1
        let commodity_id = Uuid::new_v4();
487

            
488
1
        let tx = TransactionBuilder::new()
489
1
            .id(tx_id)
490
1
            .post_date(Local::now().into())
491
1
            .enter_date(Local::now().into())
492
1
            .build()
493
1
            .unwrap();
494

            
495
1
        let split1 = Split {
496
1
            id: split1_id,
497
1
            tx_id,
498
1
            account_id: Uuid::new_v4(),
499
1
            commodity_id,
500
1
            value_num: -5000,
501
1
            value_denom: 100,
502
1
            reconcile_state: None,
503
1
            reconcile_date: None,
504
1
            lot_id: None,
505
1
        };
506

            
507
1
        let split2 = Split {
508
1
            id: split2_id,
509
1
            tx_id,
510
1
            account_id: Uuid::new_v4(),
511
1
            commodity_id,
512
1
            value_num: 5000,
513
1
            value_denom: 100,
514
1
            reconcile_state: None,
515
1
            reconcile_date: None,
516
1
            lot_id: None,
517
1
        };
518

            
519
1
        let category_tag = Tag {
520
1
            id: Uuid::new_v4(),
521
1
            tag_name: "category".to_string(),
522
1
            tag_value: "food".to_string(),
523
1
            description: None,
524
1
        };
525

            
526
1
        let state = TransactionState::new(tx)
527
1
            .with(vec![
528
1
                FinanceEntity::Split(split1),
529
1
                FinanceEntity::Split(split2),
530
            ])
531
1
            .with_note(Some("groceries".to_string()))
532
1
            .with_split_tags(vec![(split1_id, category_tag)]);
533

            
534
1
        assert_eq!(state.transaction_tags.len(), 1);
535
1
        assert_eq!(state.split_tags.len(), 1);
536

            
537
1
        let script_id = Uuid::new_v4();
538
1
        let executor = ScriptExecutor::new();
539
1
        let state = state
540
1
            .run_scripts(&executor, &[(script_id, TAG_SYNC_WASM.to_vec())])
541
1
            .expect("run_scripts failed");
542

            
543
1
        let new_tx_tags: Vec<_> = state
544
1
            .transaction_tags
545
1
            .iter()
546
2
            .filter(|t| t.tag_name != "note")
547
1
            .collect();
548
1
        assert_eq!(
549
1
            new_tx_tags.len(),
550
            1,
551
            "tag_sync should copy category tag from split to transaction"
552
        );
553
1
        assert_eq!(new_tx_tags[0].tag_name, "category");
554
1
        assert_eq!(new_tx_tags[0].tag_value, "food");
555
1
    }
556
}