Bläddra i källkod

Added order-independent balance assertion verification.

Kestrel 2 veckor sedan
förälder
incheckning
304a3fdb6f
4 ändrade filer med 101 tillägg och 36 borttagningar
  1. 65 0
      src/check.rs
  2. 12 12
      src/cmd/txnmatch.rs
  3. 22 20
      src/data.rs
  4. 2 4
      src/data/format.rs

+ 65 - 0
src/check.rs

@@ -1,5 +1,7 @@
 use std::collections::BTreeMap;
 
+use itertools::Itertools;
+
 use ariadne::Span as ASpan;
 use chumsky::span::Span as CSpan;
 
@@ -105,6 +107,61 @@ fn check_precision(root: &data::Hoard) -> Result<(), DataError> {
     Ok(())
 }
 
+fn check_balance_group<'a>(
+    account: data::AccountName,
+    running: &mut BTreeMap<data::UnitName, Spanned<data::Decimal>>,
+    group: impl Iterator<Item = &'a data::TransactionRef>,
+) -> Result<(), DataError> {
+    let group = group.collect_vec();
+    let group_len = group.len();
+
+    'order_loop: for order in group.iter().permutations(group_len) {
+        let mut balance = running.clone();
+
+        for txn_ref in order {
+            let txn = txn_ref.borrow();
+            let change = txn.change_for(account).unwrap();
+
+            let bal = balance
+                .entry(*change.unit)
+                .or_insert_with(|| Spanned::new(data::Decimal::default(), io::Span::default()));
+            bal.0 = bal.checked_add(*change.amount).unwrap();
+            bal.1 = change.source.unwrap();
+
+            // check if assertion(s) are met
+            if let Some(assertion) = change.balance.as_ref() {
+                if **assertion != bal.0 {
+                    continue 'order_loop;
+                }
+            }
+        }
+
+        /* found an ordering that works! */
+        *running = balance;
+        return Ok(());
+    }
+
+    let first = group[0].borrow();
+    let report = ariadne::Report::build(
+        ariadne::ReportKind::Error,
+        first.source.unwrap().union(first.change_for(account).unwrap().source.unwrap()),
+    )
+    .with_message("No valid ordering of transactions to satisfy balance assertions")
+    .with_labels(
+        group
+            .iter()
+            .map(|txn| {
+                let txn = txn.borrow();
+                let span = txn.source.unwrap().union(txn.change_for(account).unwrap().source.unwrap());
+                ariadne::Label::new(span)
+                    .with_message("transaction in group")
+            }),
+    )
+    .finish();
+
+    Err(DataError::Report(Box::new(report)))
+}
+
 fn check_balances(root: &data::Hoard) -> Result<(), DataError> {
     log::trace!("Checking balance consistency...");
 
@@ -114,6 +171,13 @@ fn check_balances(root: &data::Hoard) -> Result<(), DataError> {
         };
 
         let mut running_balance = BTreeMap::<data::UnitName, Spanned<data::Decimal>>::new();
+        let date_groups = ledger.iter().chunk_by(|tx| tx.borrow().datestamp);
+
+        for group in date_groups.into_iter() {
+            check_balance_group(account, &mut running_balance, group.1)?;
+        }
+
+        /*
         for txn_ref in ledger {
             let txn = txn_ref.borrow();
             let change = txn.change_for(account).unwrap();
@@ -147,6 +211,7 @@ fn check_balances(root: &data::Hoard) -> Result<(), DataError> {
                 }
             }
         }
+        */
     }
     Ok(())
 }

+ 12 - 12
src/cmd/txnmatch.rs

@@ -3,6 +3,7 @@ use std::collections::BTreeSet;
 use crate::prelude::*;
 
 fn do_merge(
+    data: &data::Hoard,
     txn1_ref: &data::TransactionRef,
     txn2_ref: &data::TransactionRef,
     common: data::AccountName,
@@ -28,14 +29,14 @@ fn do_merge(
     change1.account = other2.account;
     change2.account = other1.account;
 
-    // ghost whichever is the deposit
-    if change1.amount.is_sign_positive() {
-        txn1.annotations
-            .insert("ghost".to_string(), txn2.id().unwrap().to_string());
-    } else {
-        txn2.annotations
-            .insert("ghost".to_string(), txn1.id().unwrap().to_string());
-    }
+    // generate unused ID
+    let merge_id = loop {
+        let candidate = nanoid::nanoid!(10);
+        if !data.has_merge_group(&candidate) { break candidate }
+    };
+
+    txn1.annotations.insert("merge".to_string(), merge_id.clone());
+    txn2.annotations.insert("merge".to_string(), merge_id);
 }
 
 #[derive(cliask::ActionEnum)]
@@ -53,7 +54,6 @@ pub fn do_match(data: &mut data::Hoard, account: data::AccountName) -> anyhow::R
         return Ok(false);
     };
 
-    let mut killed = vec![];
     let mut skip = BTreeSet::<data::TransactionRef>::new();
 
     let mut sorted_data: Vec<_> = account_data.into();
@@ -66,7 +66,7 @@ pub fn do_match(data: &mut data::Hoard, account: data::AccountName) -> anyhow::R
             continue;
         }
         let txn = txn_ref.borrow();
-        if txn.id().is_none() {
+        if txn.merge_group().is_some() {
             continue;
         }
 
@@ -96,7 +96,7 @@ pub fn do_match(data: &mut data::Hoard, account: data::AccountName) -> anyhow::R
             }
             let otxn = otxn_ref.borrow();
 
-            if otxn.id().is_none() {
+            if otxn.merge_group().is_some() {
                 continue;
             }
 
@@ -125,7 +125,7 @@ pub fn do_match(data: &mut data::Hoard, account: data::AccountName) -> anyhow::R
                     Action::Merge => {
                         drop(txn);
                         drop(otxn);
-                        killed.push(do_merge(txn_ref, otxn_ref, account));
+                        do_merge(data, txn_ref, otxn_ref, account);
                         skip.insert(txn_ref.clone());
                         skip.insert(otxn_ref.clone());
                         break;

+ 22 - 20
src/data.rs

@@ -120,19 +120,8 @@ impl Transaction {
         self.annotations.get(label).map(String::as_str)
     }
 
-    pub fn id(&self) -> Option<&str> {
-        self.get_annotation("id")
-    }
-
-    pub fn ghost(&self) -> Option<&str> {
-        self.get_annotation("ghost")
-    }
-}
-
-fn transaction_order(tx1: &TransactionRef, tx2: &TransactionRef) -> std::cmp::Ordering {
-    match tx1.borrow().datestamp.cmp(&tx2.borrow().datestamp) {
-        std::cmp::Ordering::Equal => tx1.borrow().id().cmp(&tx2.borrow().id()),
-        cmp => cmp
+    pub fn merge_group(&self) -> Option<&str> {
+        self.get_annotation("merge")
     }
 }
 
@@ -236,7 +225,7 @@ pub struct Hoard {
     spec_root: spec::SpecRoot,
 
     comments: Vec<Spanned<String>>,
-    ghosts: Vec<Transaction>,
+    groups: BTreeMap<String, Vec<TransactionRef>>,
 
     raw_ledger_data: Vec<RawLedgerEntry>,
 
@@ -259,7 +248,7 @@ impl Hoard {
                     spec_root,
                     raw_ledger_data: vec![],
                     comments: vec![],
-                    ghosts: vec![],
+                    groups: Default::default(),
                     account_ledger_data: Default::default(),
                 };
 
@@ -356,12 +345,21 @@ impl Hoard {
                 }
             };
 
-            if tx.ghost().is_some() {
-                self.ghosts.push(tx.clone());
-                continue;
+            let txn_ref = TransactionRef(std::rc::Rc::new(std::cell::RefCell::new(tx.clone())));
+
+            if let Some(merge_group) = txn_ref.borrow().merge_group() {
+                use std::collections::btree_map::Entry;
+                match self.groups.entry(merge_group.to_string()) {
+                    Entry::Vacant(e) => {
+                        e.insert(vec![]);
+                    },
+                    Entry::Occupied(mut e) => {
+                        e.get_mut().push(txn_ref.clone());
+                        continue
+                    },
+                }
             }
 
-            let txn_ref = TransactionRef(std::rc::Rc::new(std::cell::RefCell::new(tx.clone())));
             for bal in &tx.changes {
                 self.account_ledger_data
                     .entry(*bal.account)
@@ -372,7 +370,7 @@ impl Hoard {
 
         self.account_ledger_data
             .values_mut()
-            .for_each(|v| v.sort_by(transaction_order));
+            .for_each(|v| v.sort_by_key(|tx| tx.borrow().datestamp));
     }
 
     pub fn all_raw_ledger_data(&self) -> &[RawLedgerEntry] {
@@ -428,6 +426,10 @@ impl Hoard {
         Some(running)
     }
 
+    pub fn has_merge_group(&self, name: &str) -> bool {
+        self.groups.contains_key(name)
+    }
+
     pub fn kill_transaction(&mut self, txn_ref: TransactionRef) {
         let txn = txn_ref.borrow();
         for account in txn.changes.iter().map(|v| *v.account).collect::<Vec<_>>() {

+ 2 - 4
src/data/format.rs

@@ -104,7 +104,7 @@ pub fn format_ledger<'l>(
         let mut outfile = fsdata.try_write(filename)?;
 
         for (_span, mut les) in entries {
-            les.sort_by_key(|le| le.as_transaction().map(|tx| (tx.datestamp, tx.id())));
+            les.sort_by_key(|le| le.as_transaction().map(|tx| tx.datestamp));
             for le in les {
                 match le {
                     RawLedgerEntry::Transaction(tx) => {
@@ -144,9 +144,7 @@ pub fn format_entire_ledger<'l>(
                 .map(|v| RawLedgerEntry::Comment(v.clone())),
         )
         .chain(
-            root.ghosts
-                .iter()
-                .map(|v| RawLedgerEntry::Transaction(v.clone())),
+            root.groups.values().flatten().map(|v| RawLedgerEntry::Transaction(v.borrow().clone())),
         )
         .collect_vec();