Lines
80.25 %
Functions
33.96 %
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_split_tags(mut self, tags: Vec<(Uuid, Tag)>) -> Self {
self.split_tags = tags;
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: &[(Uuid, Vec<u8>)],
) -> Result<Self, ServerError> {
for (script_id, bytecode) in scripts {
let (input, mut index_table) = serialize_state(&self);
match executor.execute(bytecode, &input, Some(DEFAULT_OUTPUT_SIZE)) {
Ok(entities) if !entities.is_empty() => {
apply_parsed_entities(&mut self, entities, &mut index_table)?;
Ok(_) => {}
Err(e) => {
log::error!(
"{}",
t!("Script %{id} failed, skipping: %{err}", id = script_id, err = e : {:?})
);
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_id = Uuid::new_v4();
let tag = Tag {
id: tag_id,
tag_name: name.clone(),
tag_value: value.clone(),
match index_table.get(&(entity.parent_idx as u32)) {
Some(&(EntityType::Transaction, tx_id)) => {
log::debug!(
"script: create tag \"{name}\"=\"{value}\" on transaction {tx_id}"
state.transaction_tags.push(tag);
Some(&(EntityType::Split, split_id)) => {
"script: create tag \"{name}\"=\"{value}\" on 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, tag_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 account_id = Uuid::from_bytes(account_id);
let commodity_id = Uuid::from_bytes(commodity_id);
let split_id = Uuid::new_v4();
"script: create split {split_id} account={account_id} value={value_num}/{value_denom}"
let split = Split {
id: split_id,
tx_id: state.transaction.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, split_id));
(EntityType::Split, Operation::Update) => {
&& let Some(split) = state.splits.iter_mut().find(|s| s.id == entity_id)
log::debug!("script: update split {entity_id} value={value_num}/{value_denom}");
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,
..
log::debug!("script: update transaction {entity_id}");
state.transaction.post_date = millis_to_datetime(post_date);
state.transaction.enter_date = millis_to_datetime(enter_date);
(EntityType::Split, Operation::Delete) => {
log::debug!("script: delete split {entity_id}");
state.splits.retain(|s| s.id != entity_id);
state.split_tags.retain(|(id, _)| *id != entity_id);
(EntityType::Tag, Operation::Delete) => {
log::debug!("script: delete tag {entity_id}");
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() {
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_serialize_state_with_split_tags() {
tag_name: "category".to_string(),
tag_value: "food".to_string(),
let state = TransactionState::new(tx)
.with(vec![FinanceEntity::Split(split)])
.with_split_tags(vec![(split_id, tag)]);
assert_eq!(index_table.len(), 2); // transaction + split
assert_eq!(index_table.get(&1).unwrap(), &(EntityType::Split, split_id));
fn test_millis_to_datetime() {
let dt = millis_to_datetime(1704067200000);
assert_eq!(dt.timestamp(), 1704067200);
fn test_tag_sync_copies_split_tags_to_transaction() {
const TAG_SYNC_WASM: &[u8] = include_bytes!("../../web/static/wasm/tag_sync.wasm");
let split1_id = Uuid::new_v4();
let split2_id = Uuid::new_v4();
let commodity_id = Uuid::new_v4();
let split1 = Split {
id: split1_id,
value_num: -5000,
value_denom: 100,
let split2 = Split {
id: split2_id,
value_num: 5000,
let category_tag = Tag {
.with(vec![
FinanceEntity::Split(split1),
FinanceEntity::Split(split2),
])
.with_note(Some("groceries".to_string()))
.with_split_tags(vec![(split1_id, category_tag)]);
let script_id = Uuid::new_v4();
let executor = ScriptExecutor::new();
let state = state
.run_scripts(&executor, &[(script_id, TAG_SYNC_WASM.to_vec())])
.expect("run_scripts failed");
let new_tx_tags: Vec<_> = state
.transaction_tags
.filter(|t| t.tag_name != "note")
.collect();
assert_eq!(
new_tx_tags.len(),
1,
"tag_sync should copy category tag from split to transaction"
assert_eq!(new_tx_tags[0].tag_name, "category");
assert_eq!(new_tx_tags[0].tag_value, "food");