summaryrefslogtreecommitdiff
path: root/src/types
diff options
context:
space:
mode:
authorPROINT <>2026-07-22 17:03:47 +0700
committerPROINT <>2026-07-22 17:03:47 +0700
commitccb6d208204fb286e469e87ad9c85a8c8b7be72a (patch)
tree5742b78074d4227d7c83fb1ad73c522e30af69a2 /src/types
parent9cb86b850699dc5b19e2582f5a2c64c5acc0a24b (diff)
Added JournalEntryRepositoryHEADmaster
Diffstat (limited to 'src/types')
-rw-r--r--src/types/journal.rs20
-rw-r--r--src/types/repository.rs192
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(_)))
+ ));
+ }
}