use async_trait::async_trait; use std::collections::HashMap; use std::sync::{Arc, Mutex}; use ulid::{Generator, Ulid}; use super::*; #[derive(Debug, thiserror::Error)] pub enum RepoError { #[error("parent account {0} not found")] ParentNotFound(AccountId), #[error(transparent)] Invalid(#[from] InvalidJournalEntry), } #[async_trait] pub trait AccountRepository: Send + Sync { async fn create(&self, new: NewAccount) -> Result; async fn get(&self, id: AccountId) -> Result, RepoError>; } #[async_trait] pub trait JournalEntryRepository: Send + Sync { async fn create(&self, new: NewJournalEntry) -> Result; async fn get(&self, id: JournalEntryId) -> Result, RepoError>; } #[derive(Default)] pub struct InMemoryAccountRepository { accounts: Mutex>, generator: Mutex, } pub struct InMemoryJournalEntryRepository { account_repository: Arc, journal_entries: Mutex>, generator: Mutex, } #[async_trait] impl AccountRepository for InMemoryAccountRepository { async fn create(&self, new: NewAccount) -> Result { let id = { let mut generator = self.generator.lock().unwrap(); // generate() is monotonic; on the rare same-tick random overflow, // recover into a valid (incremented) Ulid rather than failing. let ulid = generator .generate() .unwrap_or_else(|overflow| overflow.commit_overflow_increment()); AccountId::from_ulid(ulid) }; let mut accounts = self.accounts.lock().unwrap(); let path = match new.parent { Some(parent_id) => accounts .get(&parent_id) .ok_or(RepoError::ParentNotFound(parent_id))? .path .with_child(id), None => MaterializedPath::root(id), }; let account = Account::from_parts(id, new.account_type, new.name, new.description, path); accounts.insert(id, account.clone()); Ok(account) } async fn get(&self, id: AccountId) -> Result, RepoError> { Ok(self.accounts.lock().unwrap().get(&id).cloned()) } } impl InMemoryJournalEntryRepository { pub fn new(account_repository: Arc) -> Self { InMemoryJournalEntryRepository { account_repository, journal_entries: Mutex::new(HashMap::new()), generator: Mutex::new(Generator::new()), } } // Mint the next monotonic ULID, recovering from a same-tick random overflow. fn next_ulid(&self) -> Ulid { self.generator .lock() .unwrap() .generate() .unwrap_or_else(|overflow| overflow.commit_overflow_increment()) } } #[async_trait] impl JournalEntryRepository for InMemoryJournalEntryRepository { async fn create(&self, new: NewJournalEntry) -> Result { // 1. pure validation: balance, posting count, amounts new.validate()?; // 2. every referenced account must exist (no lock held across awaits) for posting in &new.postings { self.account_repository .get(posting.account_id) .await? .ok_or(InvalidJournalEntry::AccountNotFound(posting.account_id))?; } // 3. mint ids and assemble the persisted entry let id = JournalEntryId::from_ulid(self.next_ulid()); let postings: Vec = new .postings .iter() .map(|p| { Posting::from_parts( PostingId::from_ulid(self.next_ulid()), p.posting_type, p.account_id, p.amount, ) }) .collect(); let entry = JournalEntry::from_parts(id, new.date, postings, new.description); // 4. persist — lock only now, after all awaits are done self.journal_entries .lock() .unwrap() .insert(id, entry.clone()); Ok(entry) } async fn get(&self, id: JournalEntryId) -> Result, RepoError> { Ok(self.journal_entries.lock().unwrap().get(&id).cloned()) } } #[cfg(test)] mod tests { use super::*; use chrono::DateTime; fn new_account( account_type: AccountType, name: &str, parent: Option, ) -> NewAccount { NewAccount { account_type, name: name.into(), description: String::new(), parent, } } // Seed the five standard chart-of-accounts roots, each with no parent. async fn seed_roots(repo: &InMemoryAccountRepository) -> Vec { let roots = [ (AccountType::Asset, "Assets"), (AccountType::Liability, "Liabilities"), (AccountType::Equity, "Equity"), (AccountType::Revenue, "Revenue"), (AccountType::Expense, "Expenses"), ]; let mut created = Vec::new(); for (account_type, name) in roots { let account = repo .create(new_account(account_type, name, None)) .await .unwrap(); created.push(account); } created } #[tokio::test] async fn seeds_five_distinct_root_accounts() { let repo = InMemoryAccountRepository::default(); let roots = seed_roots(&repo).await; assert_eq!(roots.len(), 5); // each root's path is just its own id — depth 1, no parent segment for root in &roots { assert_eq!(root.path.depth(), 1); } // all five ids are distinct let mut ids: Vec = roots.iter().map(|a| a.id()).collect(); ids.sort(); ids.dedup(); assert_eq!(ids.len(), 5); } #[tokio::test] async fn child_nests_under_its_parent() { let repo = InMemoryAccountRepository::default(); let roots = seed_roots(&repo).await; let assets = &roots[0]; let cash = repo .create(new_account(AccountType::Asset, "Cash", Some(assets.id()))) .await .unwrap(); // the child's path was built from the parent's assert!(assets.path.is_ancestor_of(&cash.path)); assert_eq!(cash.path.depth(), assets.path.depth() + 1); // and a grandchild nests under both let checking = repo .create(new_account(AccountType::Asset, "Checking", Some(cash.id()))) .await .unwrap(); assert!(assets.path.is_ancestor_of(&checking.path)); assert!(cash.path.is_ancestor_of(&checking.path)); assert_eq!(checking.path.depth(), 3); } #[tokio::test] async fn get_returns_a_created_account() { let repo = InMemoryAccountRepository::default(); let roots = seed_roots(&repo).await; let assets = &roots[0]; let fetched = repo.get(assets.id()).await.unwrap(); assert_eq!(fetched.map(|a| a.id()), Some(assets.id())); } #[tokio::test] async fn create_under_missing_parent_fails() { let repo = InMemoryAccountRepository::default(); seed_roots(&repo).await; // an id minted by a *different* repo is guaranteed absent from `repo` let other = InMemoryAccountRepository::default(); let stranger = other .create(new_account(AccountType::Asset, "Elsewhere", None)) .await .unwrap(); let result = repo .create(new_account(AccountType::Asset, "Orphan", Some(stranger.id()))) .await; assert!(matches!(result, Err(RepoError::ParentNotFound(_)))); } // ---- JournalEntryRepository ---- fn debit(account_id: AccountId, amount: u64) -> NewPosting { NewPosting { posting_type: PostingType::Debit, account_id, amount, } } fn credit(account_id: AccountId, amount: u64) -> NewPosting { NewPosting { posting_type: PostingType::Credit, account_id, amount, } } fn journal(postings: Vec) -> NewJournalEntry { NewJournalEntry { date: DateTime::from_timestamp(0, 0).unwrap(), description: "test".into(), postings, } } #[tokio::test] async fn posts_and_retrieves_a_balanced_entry() { let account_repo = Arc::new(InMemoryAccountRepository::default()); let cash = account_repo .create(new_account(AccountType::Asset, "Cash", None)) .await .unwrap(); let revenue = account_repo .create(new_account(AccountType::Revenue, "Revenue", None)) .await .unwrap(); let journal_repo = InMemoryJournalEntryRepository::new(account_repo.clone()); let entry = journal_repo .create(journal(vec![ debit(cash.id(), 500_000), credit(revenue.id(), 500_000), ])) .await .unwrap(); assert_eq!(entry.postings.len(), 2); // round-trips through get by its minted id let fetched = journal_repo.get(entry.id()).await.unwrap(); assert_eq!(fetched.map(|e| e.id()), Some(entry.id())); } #[tokio::test] async fn unbalanced_entry_is_rejected() { let account_repo = Arc::new(InMemoryAccountRepository::default()); let cash = account_repo .create(new_account(AccountType::Asset, "Cash", None)) .await .unwrap(); let revenue = account_repo .create(new_account(AccountType::Revenue, "Revenue", None)) .await .unwrap(); let journal_repo = InMemoryJournalEntryRepository::new(account_repo.clone()); let result = journal_repo .create(journal(vec![ debit(cash.id(), 500_000), credit(revenue.id(), 499_999), ])) .await; assert!(matches!( result, Err(RepoError::Invalid(InvalidJournalEntry::Unbalanced)) )); } #[tokio::test] async fn posting_to_unknown_account_is_rejected() { let account_repo = Arc::new(InMemoryAccountRepository::default()); let cash = account_repo .create(new_account(AccountType::Asset, "Cash", None)) .await .unwrap(); // an id minted by a different repo is absent from account_repo let other = InMemoryAccountRepository::default(); let stranger = other .create(new_account(AccountType::Revenue, "Elsewhere", None)) .await .unwrap(); let journal_repo = InMemoryJournalEntryRepository::new(account_repo.clone()); let result = journal_repo .create(journal(vec![ debit(cash.id(), 100), credit(stranger.id(), 100), ])) .await; assert!(matches!( result, Err(RepoError::Invalid(InvalidJournalEntry::AccountNotFound(_))) )); } }