Lines
72.7 %
Functions
25.53 %
Branches
100 %
use finance::price::Price;
use finance::split::Split;
use finance::tag::Tag;
use finance::transaction::Transaction;
use scripting::{
ContextType, EntityData, EntityType, MemorySerializer, Operation, ParsedEntity, ScriptExecutor,
};
use sqlx::types::Uuid;
use sqlx::types::chrono::{DateTime, TimeZone, Utc};
use std::collections::HashMap;
use crate::command::FinanceEntity;
use crate::error::ServerError;
const DEFAULT_OUTPUT_SIZE: u32 = 64 * 1024;
type IndexTable = HashMap<u32, (EntityType, Uuid)>;
pub struct TransactionState {
pub transaction: Transaction,
pub splits: Vec<Split>,
pub transaction_tags: Vec<Tag>,
pub split_tags: Vec<(Uuid, Tag)>,
pub prices: Vec<Price>,
}
impl TransactionState {
#[must_use]
pub fn new(transaction: Transaction) -> Self {
Self {
transaction,
splits: Vec::new(),
transaction_tags: Vec::new(),
split_tags: Vec::new(),
prices: Vec::new(),
pub fn with(mut self, entities: Vec<FinanceEntity>) -> Self {
for entity in entities {
match entity {
FinanceEntity::Split(s) => self.splits.push(s),
FinanceEntity::Price(p) => self.prices.push(p),
FinanceEntity::Tag(t) => self.transaction_tags.push(t),
_ => {}
self
pub fn with_note(mut self, note: Option<String>) -> Self {
if let Some(note) = note {
self.transaction_tags.push(Tag {
id: Uuid::new_v4(),
tag_name: "note".to_string(),
tag_value: note,
description: None,
});
pub fn run_scripts(
mut self,
executor: &ScriptExecutor,
scripts: &[Vec<u8>],
) -> Result<Self, ServerError> {
for bytecode in scripts {
let (input, mut index_table) = serialize_state(&self);
let entities = executor.execute(bytecode, &input, Some(DEFAULT_OUTPUT_SIZE))?;
if !entities.is_empty() {
apply_parsed_entities(&mut self, entities, &mut index_table)?;
Ok(self)
fn serialize_state(state: &TransactionState) -> (Vec<u8>, IndexTable) {
let mut serializer = MemorySerializer::new();
let mut index_table = IndexTable::new();
serializer.set_context(ContextType::EntityCreate, EntityType::Transaction);
let is_multi_currency = state
.splits
.iter()
.map(|s| s.commodity_id)
.collect::<std::collections::HashSet<_>>()
.len()
> 1;
let tx_idx = serializer.add_transaction_from(
&state.transaction,
true,
state.splits.len() as u32,
state.transaction_tags.len() as u32,
is_multi_currency,
);
serializer.set_primary(tx_idx);
index_table.insert(tx_idx, (EntityType::Transaction, state.transaction.id));
let mut split_indices: Vec<(Uuid, u32)> = Vec::new();
for split in &state.splits {
let split_idx = serializer.add_split_from(split, tx_idx as i32);
split_indices.push((split.id, split_idx));
index_table.insert(split_idx, (EntityType::Split, split.id));
for tag in &state.transaction_tags {
serializer.add_tag(
*tag.id.as_bytes(),
tx_idx as i32,
false,
&tag.tag_name,
&tag.tag_value,
for (split_id, tag) in &state.split_tags {
let parent_idx = split_indices
.find(|(id, _)| id == split_id)
.map_or(-1, |(_, idx)| *idx as i32);
parent_idx,
(serializer.finalize(DEFAULT_OUTPUT_SIZE), index_table)
fn apply_parsed_entities(
state: &mut TransactionState,
entities: Vec<ParsedEntity>,
index_table: &mut IndexTable,
) -> Result<(), ServerError> {
let mut current_output_idx = index_table.len() as u32;
let entity_id = Uuid::from_bytes(entity.id);
match (entity.entity_type, entity.operation) {
(EntityType::Tag, Operation::Create) => {
if let EntityData::Tag { name, value } = entity.data {
let tag = Tag {
id: entity_id,
tag_name: name,
tag_value: value,
match index_table.get(&(entity.parent_idx as u32)) {
Some(&(EntityType::Transaction, _)) => {
state.transaction_tags.push(tag);
Some(&(EntityType::Split, split_id)) => {
state.split_tags.push((split_id, tag));
_ => {
log::warn!(
"Tag parent_idx {} not found in index table",
entity.parent_idx
index_table.insert(current_output_idx, (EntityType::Tag, entity_id));
current_output_idx += 1;
(EntityType::Split, Operation::Create) => {
if let EntityData::Split {
account_id,
commodity_id,
value_num,
value_denom,
reconcile_state,
reconcile_date,
} = entity.data
{
let split = Split {
tx_id: state.transaction.id,
account_id: Uuid::from_bytes(account_id),
commodity_id: Uuid::from_bytes(commodity_id),
reconcile_state: if reconcile_state == 0 {
None
} else {
Some(reconcile_state != 0)
},
reconcile_date: if reconcile_date == 0 {
Some(
Utc.timestamp_millis_opt(reconcile_date)
.single()
.unwrap_or_default(),
)
lot_id: None,
state.splits.push(split);
index_table.insert(current_output_idx, (EntityType::Split, entity_id));
(EntityType::Split, Operation::Update) => {
&& let Some(split) = state.splits.iter_mut().find(|s| s.id == entity_id)
split.account_id = Uuid::from_bytes(account_id);
split.commodity_id = Uuid::from_bytes(commodity_id);
split.value_num = value_num;
split.value_denom = value_denom;
split.reconcile_state = if reconcile_state == 0 {
split.reconcile_date = if reconcile_date == 0 {
(EntityType::Transaction, Operation::Update) => {
if let EntityData::Transaction {
post_date,
enter_date,
..
state.transaction.post_date = millis_to_datetime(post_date);
state.transaction.enter_date = millis_to_datetime(enter_date);
(EntityType::Split, Operation::Delete) => {
state.splits.retain(|s| s.id != entity_id);
state.split_tags.retain(|(id, _)| *id != entity_id);
(EntityType::Tag, Operation::Delete) => {
state.transaction_tags.retain(|t| t.id != entity_id);
state.split_tags.retain(|(_, t)| t.id != entity_id);
Ok(())
fn millis_to_datetime(millis: i64) -> DateTime<Utc> {
Utc.timestamp_millis_opt(millis)
.unwrap_or_default()
#[cfg(test)]
mod tests {
use super::*;
use finance::transaction::TransactionBuilder;
use sqlx::types::chrono::Local;
#[test]
fn test_transaction_state_new() {
let tx = TransactionBuilder::new()
.id(Uuid::new_v4())
.post_date(Local::now().into())
.enter_date(Local::now().into())
.build()
.unwrap();
let state = TransactionState::new(tx);
assert!(state.splits.is_empty());
assert!(state.transaction_tags.is_empty());
assert!(state.split_tags.is_empty());
assert!(state.prices.is_empty());
fn test_serialize_empty_state() {
let (bytes, index_table) = serialize_state(&state);
assert!(!bytes.is_empty());
assert!(index_table.get(&0).is_some()); // Transaction at index 0
fn test_apply_tag_to_transaction() {
let tx_id = Uuid::new_v4();
.id(tx_id)
let mut state = TransactionState::new(tx);
index_table.insert(0, (EntityType::Transaction, tx_id));
let tag_entity = ParsedEntity {
entity_type: EntityType::Tag,
operation: Operation::Create,
flags: 0,
id: *Uuid::new_v4().as_bytes(),
parent_idx: 0, // Points to transaction at index 0
data: EntityData::Tag {
name: "category".to_string(),
value: "groceries".to_string(),
apply_parsed_entities(&mut state, vec![tag_entity], &mut index_table).unwrap();
assert_eq!(state.transaction_tags.len(), 1);
assert_eq!(state.transaction_tags[0].tag_name, "category");
assert_eq!(state.transaction_tags[0].tag_value, "groceries");
fn test_apply_tag_to_split() {
let split_id = Uuid::new_v4();
id: split_id,
tx_id,
account_id: Uuid::new_v4(),
commodity_id: Uuid::new_v4(),
value_num: 100,
value_denom: 1,
reconcile_state: None,
reconcile_date: None,
let mut state = TransactionState::new(tx).with(vec![FinanceEntity::Split(split)]);
index_table.insert(1, (EntityType::Split, split_id));
parent_idx: 1, // Points to split at index 1
assert_eq!(state.split_tags.len(), 1);
assert_eq!(state.split_tags[0].0, split_id);
assert_eq!(state.split_tags[0].1.tag_name, "category");
assert_eq!(state.split_tags[0].1.tag_value, "groceries");
fn test_millis_to_datetime() {
let dt = millis_to_datetime(1704067200000);
assert_eq!(dt.timestamp(), 1704067200);