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
110
    pub fn new(transaction: Transaction) -> Self {
30
110
        Self {
31
110
            transaction,
32
110
            splits: Vec::new(),
33
110
            transaction_tags: Vec::new(),
34
110
            split_tags: Vec::new(),
35
110
            prices: Vec::new(),
36
110
        }
37
110
    }
38

            
39
    #[must_use]
40
213
    pub fn with(mut self, entities: Vec<FinanceEntity>) -> Self {
41
214
        for entity in entities {
42
214
            match entity {
43
213
                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
213
        self
50
213
    }
51

            
52
    #[must_use]
53
106
    pub fn with_note(mut self, note: Option<String>) -> Self {
54
106
        if let Some(note) = note {
55
14
            self.transaction_tags.push(Tag {
56
14
                id: Uuid::new_v4(),
57
14
                tag_name: "note".to_string(),
58
14
                tag_value: note,
59
14
                description: None,
60
14
            });
61
92
        }
62
106
        self
63
106
    }
64

            
65
2
    pub fn run_scripts(
66
2
        mut self,
67
2
        executor: &ScriptExecutor,
68
2
        scripts: &[Vec<u8>],
69
2
    ) -> Result<Self, ServerError> {
70
2
        for bytecode in scripts {
71
2
            let (input, mut index_table) = serialize_state(&self);
72
2
            let entities = executor.execute(bytecode, &input, Some(DEFAULT_OUTPUT_SIZE))?;
73

            
74
2
            if !entities.is_empty() {
75
1
                apply_parsed_entities(&mut self, entities, &mut index_table)?;
76
1
            }
77
        }
78
2
        Ok(self)
79
2
    }
80
}
81

            
82
3
fn serialize_state(state: &TransactionState) -> (Vec<u8>, IndexTable) {
83
3
    let mut serializer = MemorySerializer::new();
84
3
    let mut index_table = IndexTable::new();
85

            
86
3
    serializer.set_context(ContextType::EntityCreate, EntityType::Transaction);
87

            
88
3
    let is_multi_currency = state
89
3
        .splits
90
3
        .iter()
91
3
        .map(|s| s.commodity_id)
92
3
        .collect::<std::collections::HashSet<_>>()
93
3
        .len()
94
        > 1;
95

            
96
3
    let tx_idx = serializer.add_transaction_from(
97
3
        &state.transaction,
98
        true,
99
3
        state.splits.len() as u32,
100
3
        state.transaction_tags.len() as u32,
101
3
        is_multi_currency,
102
    );
103
3
    serializer.set_primary(tx_idx);
104
3
    index_table.insert(tx_idx, (EntityType::Transaction, state.transaction.id));
105

            
106
3
    let mut split_indices: Vec<(Uuid, u32)> = Vec::new();
107

            
108
4
    for split in &state.splits {
109
4
        let split_idx = serializer.add_split_from(split, tx_idx as i32);
110
4
        split_indices.push((split.id, split_idx));
111
4
        index_table.insert(split_idx, (EntityType::Split, split.id));
112
4
    }
113

            
114
3
    for tag in &state.transaction_tags {
115
2
        serializer.add_tag(
116
2
            *tag.id.as_bytes(),
117
2
            tx_idx as i32,
118
2
            false,
119
2
            false,
120
2
            &tag.tag_name,
121
2
            &tag.tag_value,
122
2
        );
123
2
    }
124

            
125
3
    for (split_id, tag) in &state.split_tags {
126
        let parent_idx = split_indices
127
            .iter()
128
            .find(|(id, _)| id == split_id)
129
            .map_or(-1, |(_, idx)| *idx as i32);
130

            
131
        serializer.add_tag(
132
            *tag.id.as_bytes(),
133
            parent_idx,
134
            false,
135
            false,
136
            &tag.tag_name,
137
            &tag.tag_value,
138
        );
139
    }
140

            
141
3
    (serializer.finalize(DEFAULT_OUTPUT_SIZE), index_table)
142
3
}
143

            
144
3
fn apply_parsed_entities(
145
3
    state: &mut TransactionState,
146
3
    entities: Vec<ParsedEntity>,
147
3
    index_table: &mut IndexTable,
148
3
) -> Result<(), ServerError> {
149
3
    let mut current_output_idx = index_table.len() as u32;
150

            
151
4
    for entity in entities {
152
4
        let entity_id = Uuid::from_bytes(entity.id);
153

            
154
4
        match (entity.entity_type, entity.operation) {
155
            (EntityType::Tag, Operation::Create) => {
156
4
                if let EntityData::Tag { name, value } = entity.data {
157
4
                    let tag = Tag {
158
4
                        id: entity_id,
159
4
                        tag_name: name,
160
4
                        tag_value: value,
161
4
                        description: None,
162
4
                    };
163

            
164
4
                    match index_table.get(&(entity.parent_idx as u32)) {
165
1
                        Some(&(EntityType::Transaction, _)) => {
166
1
                            state.transaction_tags.push(tag);
167
1
                        }
168
3
                        Some(&(EntityType::Split, split_id)) => {
169
3
                            state.split_tags.push((split_id, tag));
170
3
                        }
171
                        _ => {
172
                            log::warn!(
173
                                "Tag parent_idx {} not found in index table",
174
                                entity.parent_idx
175
                            );
176
                        }
177
                    }
178

            
179
4
                    index_table.insert(current_output_idx, (EntityType::Tag, entity_id));
180
4
                    current_output_idx += 1;
181
                }
182
            }
183
            (EntityType::Split, Operation::Create) => {
184
                if let EntityData::Split {
185
                    account_id,
186
                    commodity_id,
187
                    value_num,
188
                    value_denom,
189
                    reconcile_state,
190
                    reconcile_date,
191
                } = entity.data
192
                {
193
                    let split = Split {
194
                        id: entity_id,
195
                        tx_id: state.transaction.id,
196
                        account_id: Uuid::from_bytes(account_id),
197
                        commodity_id: Uuid::from_bytes(commodity_id),
198
                        value_num,
199
                        value_denom,
200
                        reconcile_state: if reconcile_state == 0 {
201
                            None
202
                        } else {
203
                            Some(reconcile_state != 0)
204
                        },
205
                        reconcile_date: if reconcile_date == 0 {
206
                            None
207
                        } else {
208
                            Some(
209
                                Utc.timestamp_millis_opt(reconcile_date)
210
                                    .single()
211
                                    .unwrap_or_default(),
212
                            )
213
                        },
214
                        lot_id: None,
215
                    };
216
                    state.splits.push(split);
217

            
218
                    index_table.insert(current_output_idx, (EntityType::Split, entity_id));
219
                    current_output_idx += 1;
220
                }
221
            }
222
            (EntityType::Split, Operation::Update) => {
223
                if let EntityData::Split {
224
                    account_id,
225
                    commodity_id,
226
                    value_num,
227
                    value_denom,
228
                    reconcile_state,
229
                    reconcile_date,
230
                } = entity.data
231
                    && let Some(split) = state.splits.iter_mut().find(|s| s.id == entity_id)
232
                {
233
                    split.account_id = Uuid::from_bytes(account_id);
234
                    split.commodity_id = Uuid::from_bytes(commodity_id);
235
                    split.value_num = value_num;
236
                    split.value_denom = value_denom;
237
                    split.reconcile_state = if reconcile_state == 0 {
238
                        None
239
                    } else {
240
                        Some(reconcile_state != 0)
241
                    };
242
                    split.reconcile_date = if reconcile_date == 0 {
243
                        None
244
                    } else {
245
                        Some(
246
                            Utc.timestamp_millis_opt(reconcile_date)
247
                                .single()
248
                                .unwrap_or_default(),
249
                        )
250
                    };
251
                }
252
            }
253
            (EntityType::Transaction, Operation::Update) => {
254
                if let EntityData::Transaction {
255
                    post_date,
256
                    enter_date,
257
                    ..
258
                } = entity.data
259
                {
260
                    state.transaction.post_date = millis_to_datetime(post_date);
261
                    state.transaction.enter_date = millis_to_datetime(enter_date);
262
                }
263
            }
264
            (EntityType::Split, Operation::Delete) => {
265
                state.splits.retain(|s| s.id != entity_id);
266
                state.split_tags.retain(|(id, _)| *id != entity_id);
267
            }
268
            (EntityType::Tag, Operation::Delete) => {
269
                state.transaction_tags.retain(|t| t.id != entity_id);
270
                state.split_tags.retain(|(_, t)| t.id != entity_id);
271
            }
272
            _ => {}
273
        }
274
    }
275
3
    Ok(())
276
3
}
277

            
278
1
fn millis_to_datetime(millis: i64) -> DateTime<Utc> {
279
1
    Utc.timestamp_millis_opt(millis)
280
1
        .single()
281
1
        .unwrap_or_default()
282
1
}
283

            
284
#[cfg(test)]
285
mod tests {
286
    use super::*;
287
    use finance::transaction::TransactionBuilder;
288
    use sqlx::types::chrono::Local;
289

            
290
    #[test]
291
1
    fn test_transaction_state_new() {
292
1
        let tx = TransactionBuilder::new()
293
1
            .id(Uuid::new_v4())
294
1
            .post_date(Local::now().into())
295
1
            .enter_date(Local::now().into())
296
1
            .build()
297
1
            .unwrap();
298

            
299
1
        let state = TransactionState::new(tx);
300
1
        assert!(state.splits.is_empty());
301
1
        assert!(state.transaction_tags.is_empty());
302
1
        assert!(state.split_tags.is_empty());
303
1
        assert!(state.prices.is_empty());
304
1
    }
305

            
306
    #[test]
307
1
    fn test_serialize_empty_state() {
308
1
        let tx = TransactionBuilder::new()
309
1
            .id(Uuid::new_v4())
310
1
            .post_date(Local::now().into())
311
1
            .enter_date(Local::now().into())
312
1
            .build()
313
1
            .unwrap();
314

            
315
1
        let state = TransactionState::new(tx);
316
1
        let (bytes, index_table) = serialize_state(&state);
317
1
        assert!(!bytes.is_empty());
318
1
        assert!(index_table.get(&0).is_some()); // Transaction at index 0
319
1
    }
320

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

            
331
1
        let mut state = TransactionState::new(tx);
332
1
        let mut index_table = IndexTable::new();
333
1
        index_table.insert(0, (EntityType::Transaction, tx_id));
334

            
335
1
        let tag_entity = ParsedEntity {
336
1
            entity_type: EntityType::Tag,
337
1
            operation: Operation::Create,
338
1
            flags: 0,
339
1
            id: *Uuid::new_v4().as_bytes(),
340
1
            parent_idx: 0, // Points to transaction at index 0
341
1
            data: EntityData::Tag {
342
1
                name: "category".to_string(),
343
1
                value: "groceries".to_string(),
344
1
            },
345
1
        };
346

            
347
1
        apply_parsed_entities(&mut state, vec![tag_entity], &mut index_table).unwrap();
348
1
        assert_eq!(state.transaction_tags.len(), 1);
349
1
        assert_eq!(state.transaction_tags[0].tag_name, "category");
350
1
        assert_eq!(state.transaction_tags[0].tag_value, "groceries");
351
1
    }
352

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

            
364
1
        let split = Split {
365
1
            id: split_id,
366
1
            tx_id,
367
1
            account_id: Uuid::new_v4(),
368
1
            commodity_id: Uuid::new_v4(),
369
1
            value_num: 100,
370
1
            value_denom: 1,
371
1
            reconcile_state: None,
372
1
            reconcile_date: None,
373
1
            lot_id: None,
374
1
        };
375

            
376
1
        let mut state = TransactionState::new(tx).with(vec![FinanceEntity::Split(split)]);
377
1
        let mut index_table = IndexTable::new();
378
1
        index_table.insert(0, (EntityType::Transaction, tx_id));
379
1
        index_table.insert(1, (EntityType::Split, split_id));
380

            
381
1
        let tag_entity = ParsedEntity {
382
1
            entity_type: EntityType::Tag,
383
1
            operation: Operation::Create,
384
1
            flags: 0,
385
1
            id: *Uuid::new_v4().as_bytes(),
386
1
            parent_idx: 1, // Points to split at index 1
387
1
            data: EntityData::Tag {
388
1
                name: "category".to_string(),
389
1
                value: "groceries".to_string(),
390
1
            },
391
1
        };
392

            
393
1
        apply_parsed_entities(&mut state, vec![tag_entity], &mut index_table).unwrap();
394
1
        assert_eq!(state.split_tags.len(), 1);
395
1
        assert_eq!(state.split_tags[0].0, split_id);
396
1
        assert_eq!(state.split_tags[0].1.tag_name, "category");
397
1
        assert_eq!(state.split_tags[0].1.tag_value, "groceries");
398
1
    }
399

            
400
    #[test]
401
1
    fn test_millis_to_datetime() {
402
1
        let dt = millis_to_datetime(1704067200000);
403
1
        assert_eq!(dt.timestamp(), 1704067200);
404
1
    }
405
}