diff options
Diffstat (limited to 'src/types')
| -rw-r--r-- | src/types/journal.rs | 20 | ||||
| -rw-r--r-- | src/types/repository.rs | 192 |
2 files changed, 209 insertions, 3 deletions
diff --git a/src/types/journal.rs b/src/types/journal.rs index b09c50f..7dd3d63 100644 --- a/src/types/journal.rs +++ b/src/types/journal.rs @@ -22,12 +22,13 @@ pub enum InvalidJournalEntry { AmountTooLarge(u64), } -#[derive(PartialEq)] +#[derive(PartialEq, Clone, Copy)] pub enum PostingType { Debit, Credit } +#[derive(Clone)] pub struct Posting { posting_id: PostingId, pub posting_type: PostingType, @@ -41,6 +42,12 @@ pub struct NewPosting { pub amount: u64 } +impl Posting { + pub(crate) fn from_parts(posting_id: PostingId, posting_type: PostingType, account_id: AccountId, amount: u64) -> Self { + Posting { posting_id, posting_type, account_id, amount } + } +} + impl NewPosting { fn new(posting_type: PostingType, account_id: AccountId, amount: u64) -> NewPosting { NewPosting { @@ -51,6 +58,7 @@ impl NewPosting { } } +#[derive(Clone)] pub struct JournalEntry { journal_entry_id: JournalEntryId, pub date: DateTime<Utc>, @@ -64,6 +72,16 @@ pub struct NewJournalEntry { pub description: String } +impl JournalEntry { + pub(crate) fn from_parts(journal_entry_id: JournalEntryId, date: DateTime<Utc>, postings: Vec<Posting>, description: String) -> Self { + JournalEntry { journal_entry_id, date, postings, description } + } + + pub fn id(&self) -> JournalEntryId { + self.journal_entry_id + } +} + impl NewJournalEntry { fn new(date: DateTime<Utc>, postings: Vec<NewPosting>, description: impl Into<String>) -> NewJournalEntry { NewJournalEntry { diff --git a/src/types/repository.rs b/src/types/repository.rs index 0bef30e..5b8f08e 100644 --- a/src/types/repository.rs +++ b/src/types/repository.rs @@ -1,7 +1,7 @@ use async_trait::async_trait; use std::collections::HashMap; -use std::sync::Mutex; -use ulid::Generator; +use std::sync::{Arc, Mutex}; +use ulid::{Generator, Ulid}; use super::*; @@ -9,6 +9,8 @@ use super::*; pub enum RepoError { #[error("parent account {0} not found")] ParentNotFound(AccountId), + #[error(transparent)] + Invalid(#[from] InvalidJournalEntry), } #[async_trait] @@ -17,12 +19,24 @@ pub trait AccountRepository: Send + Sync { async fn get(&self, id: AccountId) -> Result<Option<Account>, RepoError>; } +#[async_trait] +pub trait JournalEntryRepository: Send + Sync { + async fn create(&self, new: NewJournalEntry) -> Result<JournalEntry, RepoError>; + async fn get(&self, id: JournalEntryId) -> Result<Option<JournalEntry>, RepoError>; +} + #[derive(Default)] pub struct InMemoryAccountRepository { accounts: Mutex<HashMap<AccountId, Account>>, generator: Mutex<Generator>, } +pub struct InMemoryJournalEntryRepository { + account_repository: Arc<dyn AccountRepository>, + journal_entries: Mutex<HashMap<JournalEntryId, JournalEntry>>, + generator: Mutex<Generator>, +} + #[async_trait] impl AccountRepository for InMemoryAccountRepository { async fn create(&self, new: NewAccount) -> Result<Account, RepoError> { @@ -58,9 +72,71 @@ impl AccountRepository for InMemoryAccountRepository { } } +impl InMemoryJournalEntryRepository { + pub fn new(account_repository: Arc<dyn AccountRepository>) -> 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<JournalEntry, RepoError> { + // 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<Posting> = 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<Option<JournalEntry>, 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, @@ -168,4 +244,116 @@ mod tests { 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<NewPosting>) -> 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(_))) + )); + } } |
