Lab 05: Mini Ledger with Double-Entry and Quarantine

Goal

Build a minimal double-entry ledger enforcing invariants with quarantine.

Structure

lab-05-mini-ledger/
├── Cargo.toml
└── src/
    ├── main.rs
    ├── ledger.rs       # Core ledger logic
    ├── entry.rs        # Entry and Posting types
    └── quarantine.rs   # Quarantine storage

Requirements

1. Core Types

pub struct Entry {
    pub id: String,
    pub postings: Vec<Posting>,
    pub timestamp: u64,
}

pub struct Posting {
    pub account: String,
    pub amount: i64,  // Positive = credit, negative = debit
}

2. Double-Entry Invariant

impl Ledger {
    pub fn submit(&mut self, entry: Entry) -> Result<(), LedgerError> {
        self.validate(&entry)?;
        self.store.insert(entry.id.clone(), entry);
        Ok(())
    }
    
    fn validate(&self, entry: &Entry) -> Result<(), LedgerError> {
        let sum: i64 = entry.postings.iter().map(|p| p.amount).sum();
        if sum != 0 {
            return Err(LedgerError::UnbalancedEntry { sum });
        }
        Ok(())
    }
}

3. Quarantine Pattern

pub struct QuarantinedEntry {
    pub entry: Entry,
    pub reason: String,
    pub timestamp: u64,
}

impl Ledger {
    pub fn submit(&mut self, entry: Entry) -> Result<(), LedgerError> {
        match self.validate(&entry) {
            Ok(()) => {
                self.store.insert(entry.id.clone(), entry);
                Ok(())
            }
            Err(e) => {
                self.quarantine.insert(entry.id.clone(), QuarantinedEntry {
                    entry,
                    reason: e.to_string(),
                    timestamp: unix_timestamp(),
                });
                Err(e)
            }
        }
    }
    
    pub fn get_quarantine(&self) -> &HashMap<String, QuarantinedEntry> {
        &self.quarantine
    }
}

4. Balance Queries

impl Ledger {
    pub fn get_balance(&self, account: &str) -> i64 {
        self.store.values()
            .flat_map(|entry| &entry.postings)
            .filter(|p| p.account == account)
            .map(|p| p.amount)
            .sum()
    }
}

Tests to Write

Test 1: Balanced Entry Accepted

#[test]
fn test_balanced_entry_accepted() {
    let mut ledger = Ledger::new();
    let entry = Entry {
        id: "entry-1".into(),
        postings: vec![
            Posting { account: "alice".into(), amount: -100 },
            Posting { account: "bob".into(), amount: 100 },
        ],
        timestamp: 0,
    };
    
    assert!(ledger.submit(entry).is_ok());
    assert_eq!(ledger.get_balance("alice"), -100);
    assert_eq!(ledger.get_balance("bob"), 100);
}

Test 2: Unbalanced Entry Quarantined

#[test]
fn test_unbalanced_entry_quarantined() {
    let mut ledger = Ledger::new();
    let entry = Entry {
        id: "entry-2".into(),
        postings: vec![
            Posting { account: "alice".into(), amount: -100 },
            Posting { account: "bob".into(), amount: 50 },  // Unbalanced!
        ],
        timestamp: 0,
    };
    
    let result = ledger.submit(entry.clone());
    assert!(result.is_err());
    
    // Entry should be in quarantine
    assert_eq!(ledger.get_quarantine().len(), 1);
    assert_eq!(ledger.get_quarantine().get("entry-2").unwrap().entry.id, "entry-2");
}

Test 3: Multiple Entries Balance

#[test]
fn test_multiple_entries_balance() {
    let mut ledger = Ledger::new();
    
    // Entry 1: Alice pays Bob 100
    ledger.submit(Entry {
        id: "e1".into(),
        postings: vec![
            Posting { account: "alice".into(), amount: -100 },
            Posting { account: "bob".into(), amount: 100 },
        ],
        timestamp: 1,
    }).unwrap();
    
    // Entry 2: Bob pays Carol 50
    ledger.submit(Entry {
        id: "e2".into(),
        postings: vec![
            Posting { account: "bob".into(), amount: -50 },
            Posting { account: "carol".into(), amount: 50 },
        ],
        timestamp: 2,
    }).unwrap();
    
    assert_eq!(ledger.get_balance("alice"), -100);
    assert_eq!(ledger.get_balance("bob"), 50);
    assert_eq!(ledger.get_balance("carol"), 50);
}

Done When

  • Balanced entries accepted, stored correctly
  • Unbalanced entries rejected, moved to quarantine
  • Quarantined entries retrievable with reason
  • Balance queries work across multiple entries
  • All tests pass

Resources

  • icn/crates/icn-ledger/src/ledger.rs (validation + quarantine)
  • icn-ledger/tests/dynamic_limits_integration.rs
  • Double-entry accounting: Wikipedia