diff options
Diffstat (limited to 'src/types')
| -rw-r--r-- | src/types/account.rs | 46 | ||||
| -rw-r--r-- | src/types/ids.rs | 43 | ||||
| -rw-r--r-- | src/types/journal.rs | 208 | ||||
| -rw-r--r-- | src/types/mod.rs | 11 | ||||
| -rw-r--r-- | src/types/path.rs | 37 | ||||
| -rw-r--r-- | src/types/repository.rs | 171 |
6 files changed, 516 insertions, 0 deletions
diff --git a/src/types/account.rs b/src/types/account.rs new file mode 100644 index 0000000..66796a6 --- /dev/null +++ b/src/types/account.rs @@ -0,0 +1,46 @@ +use super::ids::AccountId; +use super::path::MaterializedPath; + +#[derive(PartialEq, Clone)] +pub enum AccountType { + Asset, + Liability, + Equity, + Revenue, + Expense +} + +#[derive(Clone)] +pub struct Account { + account_id: AccountId, + pub account_type: AccountType, + pub name: String, + pub description: String, + pub path: MaterializedPath +} + +impl Account { + pub(crate) fn from_parts(account_id: AccountId, account_type: AccountType, name: String, description: String, path: MaterializedPath) -> Self { + Account { account_id, account_type, name, description, path } + } + + pub fn id(&self) -> AccountId { self.account_id } +} + +pub struct NewAccount { + pub account_type: AccountType, + pub name: String, + pub description: String, + pub parent: Option<AccountId> +} + +impl NewAccount { + fn new(account_type: AccountType, name: impl Into<String>, description: impl Into<String>, parent: Option<AccountId>) -> NewAccount { + NewAccount { + account_type, + name: name.into(), + description: description.into(), + parent + } + } +} diff --git a/src/types/ids.rs b/src/types/ids.rs new file mode 100644 index 0000000..03ebb59 --- /dev/null +++ b/src/types/ids.rs @@ -0,0 +1,43 @@ +use ulid::Ulid; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct AccountId(Ulid); + +impl AccountId { + pub(crate) fn from_ulid(ulid: Ulid) -> Self { + AccountId(ulid) + } + pub fn as_ulid(&self) -> Ulid { + self.0 + } +} + +impl std::fmt::Display for AccountId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct PostingId(Ulid); + +impl PostingId { + pub(crate) fn from_ulid(ulid: Ulid) -> Self { + PostingId(ulid) + } + pub fn as_ulid(&self) -> Ulid { + self.0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct JournalEntryId(Ulid); + +impl JournalEntryId { + pub(crate) fn from_ulid(ulid: Ulid) -> Self { + JournalEntryId(ulid) + } + pub fn as_ulid(&self) -> Ulid { + self.0 + } +} diff --git a/src/types/journal.rs b/src/types/journal.rs new file mode 100644 index 0000000..b09c50f --- /dev/null +++ b/src/types/journal.rs @@ -0,0 +1,208 @@ +use chrono::{DateTime, Utc}; + +use super::ids::{AccountId, JournalEntryId, PostingId}; + +/// Maximum magnitude for a single posting, in KRW. +/// The won has no minor units, so this is a whole-won ceiling (~700 trillion KRW). +/// A domain sanity bound: no legitimate single posting approaches it, and it keeps +/// any realistic entry's sums far clear of overflow. Tune per currency if that changes. +const MAX_POSTING_AMOUNT: u64 = 1_000_000_000_000_000; + +#[derive(Debug, thiserror::Error)] +pub enum InvalidJournalEntry { + #[error("unbalanced")] + Unbalanced, + #[error("account {0} does not exist")] + AccountNotFound(AccountId), + #[error("a journal entry needs at least 2 postings, got {0}")] + TooFewPostings(usize), + #[error("posting amount must be non-zero")] + ZeroAmount, + #[error("posting amount {0} exceeds the maximum allowed")] + AmountTooLarge(u64), +} + +#[derive(PartialEq)] +pub enum PostingType { + Debit, + Credit +} + +pub struct Posting { + posting_id: PostingId, + pub posting_type: PostingType, + pub account_id: AccountId, + pub amount: u64 +} + +pub struct NewPosting { + pub posting_type: PostingType, + pub account_id: AccountId, + pub amount: u64 +} + +impl NewPosting { + fn new(posting_type: PostingType, account_id: AccountId, amount: u64) -> NewPosting { + NewPosting { + posting_type, + account_id, + amount + } + } +} + +pub struct JournalEntry { + journal_entry_id: JournalEntryId, + pub date: DateTime<Utc>, + pub postings: Vec<Posting>, + pub description: String +} + +pub struct NewJournalEntry { + pub date: DateTime<Utc>, + pub postings: Vec<NewPosting>, + pub description: String +} + +impl NewJournalEntry { + fn new(date: DateTime<Utc>, postings: Vec<NewPosting>, description: impl Into<String>) -> NewJournalEntry { + NewJournalEntry { + date, + postings, + description: description.into() + } + } + pub fn validate(&self) -> Result<(), InvalidJournalEntry> { + if self.postings.len() < 2 { + return Err(InvalidJournalEntry::TooFewPostings(self.postings.len())); + } + let mut debit: u128 = 0; + let mut credit: u128 = 0; + for posting in &self.postings { + if posting.amount == 0 { + return Err(InvalidJournalEntry::ZeroAmount); + } + if posting.amount > MAX_POSTING_AMOUNT { + return Err(InvalidJournalEntry::AmountTooLarge(posting.amount)); + } + match posting.posting_type { + PostingType::Debit => debit += posting.amount as u128, + PostingType::Credit => credit += posting.amount as u128 + } + } + if debit != credit { + return Err(InvalidJournalEntry::Unbalanced); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ulid::Ulid; + + // validate() is pure and ignores account identity, so any dummy id works. + fn posting(posting_type: PostingType, amount: u64) -> NewPosting { + NewPosting { + posting_type, + account_id: AccountId::from_ulid(Ulid::from(1u128)), + amount, + } + } + + fn entry(postings: Vec<NewPosting>) -> NewJournalEntry { + NewJournalEntry { + date: DateTime::from_timestamp(0, 0).unwrap(), + postings, + description: String::from("test"), + } + } + + #[test] + fn balanced_two_postings_is_ok() { + let e = entry(vec![ + posting(PostingType::Debit, 1000), + posting(PostingType::Credit, 1000), + ]); + assert!(e.validate().is_ok()); + } + + #[test] + fn balanced_multiple_postings_is_ok() { + // two debits summing to one credit + let e = entry(vec![ + posting(PostingType::Debit, 600), + posting(PostingType::Debit, 400), + posting(PostingType::Credit, 1000), + ]); + assert!(e.validate().is_ok()); + } + + #[test] + fn unbalanced_is_err() { + let e = entry(vec![ + posting(PostingType::Debit, 1000), + posting(PostingType::Credit, 999), + ]); + assert!(matches!(e.validate(), Err(InvalidJournalEntry::Unbalanced))); + } + + #[test] + fn single_posting_is_too_few() { + let e = entry(vec![posting(PostingType::Debit, 1000)]); + assert!(matches!( + e.validate(), + Err(InvalidJournalEntry::TooFewPostings(1)) + )); + } + + #[test] + fn no_postings_is_too_few() { + let e = entry(vec![]); + assert!(matches!( + e.validate(), + Err(InvalidJournalEntry::TooFewPostings(0)) + )); + } + + #[test] + fn zero_amount_is_err() { + let e = entry(vec![ + posting(PostingType::Debit, 0), + posting(PostingType::Credit, 0), + ]); + assert!(matches!(e.validate(), Err(InvalidJournalEntry::ZeroAmount))); + } + + #[test] + fn amount_over_cap_is_err() { + let e = entry(vec![ + posting(PostingType::Debit, MAX_POSTING_AMOUNT + 1), + posting(PostingType::Credit, MAX_POSTING_AMOUNT + 1), + ]); + assert!(matches!( + e.validate(), + Err(InvalidJournalEntry::AmountTooLarge(_)) + )); + } + + #[test] + fn amount_at_cap_is_ok() { + let e = entry(vec![ + posting(PostingType::Debit, MAX_POSTING_AMOUNT), + posting(PostingType::Credit, MAX_POSTING_AMOUNT), + ]); + assert!(e.validate().is_ok()); + } + + #[test] + fn too_few_takes_precedence_over_zero_amount() { + // a single zero-amount posting: the count check fires first + let e = entry(vec![posting(PostingType::Debit, 0)]); + assert!(matches!( + e.validate(), + Err(InvalidJournalEntry::TooFewPostings(1)) + )); + } +} diff --git a/src/types/mod.rs b/src/types/mod.rs new file mode 100644 index 0000000..cb89841 --- /dev/null +++ b/src/types/mod.rs @@ -0,0 +1,11 @@ +mod account; +mod ids; +mod journal; +mod path; +mod repository; + +pub use account::*; +pub use ids::*; +pub use journal::*; +pub use path::*; +pub use repository::*; diff --git a/src/types/path.rs b/src/types/path.rs new file mode 100644 index 0000000..1d095cd --- /dev/null +++ b/src/types/path.rs @@ -0,0 +1,37 @@ +use ulid::Ulid; + +use super::ids::AccountId; + +#[derive(Debug, thiserror::Error)] +pub enum PathError { + #[error("path is empty")] + Empty, + #[error("invalid ULID segment: {0}")] + BadSegment(String), +} + +#[derive(Clone)] +pub struct MaterializedPath(String); + +impl MaterializedPath { + pub fn parse(s: &str) -> Result<Self, PathError> { + if s.is_empty() { + return Err(PathError::Empty); + } + for seg in s.split('/') { + Ulid::from_string(seg).map_err(|_| PathError::BadSegment(seg.to_string()))?; + } + Ok(MaterializedPath(s.to_string())) + } + pub fn as_str(&self) -> &str { &self.0 } + pub fn depth(&self) -> usize { self.0.split('/').count() } + pub fn is_ancestor_of(&self, other: &MaterializedPath) -> bool { + other.0.starts_with(&self.0) && other.0.as_bytes().get(self.0.len()) == Some(&b'/') + } + pub fn with_child(&self, id: AccountId) -> MaterializedPath { + MaterializedPath(format!("{}/{}", self.0, id.as_ulid())) + } + pub fn root(id: AccountId) -> MaterializedPath { + MaterializedPath(id.as_ulid().to_string()) + } +} diff --git a/src/types/repository.rs b/src/types/repository.rs new file mode 100644 index 0000000..0bef30e --- /dev/null +++ b/src/types/repository.rs @@ -0,0 +1,171 @@ +use async_trait::async_trait; +use std::collections::HashMap; +use std::sync::Mutex; +use ulid::Generator; + +use super::*; + +#[derive(Debug, thiserror::Error)] +pub enum RepoError { + #[error("parent account {0} not found")] + ParentNotFound(AccountId), +} + +#[async_trait] +pub trait AccountRepository: Send + Sync { + async fn create(&self, new: NewAccount) -> Result<Account, RepoError>; + async fn get(&self, id: AccountId) -> Result<Option<Account>, RepoError>; +} + +#[derive(Default)] +pub struct InMemoryAccountRepository { + accounts: Mutex<HashMap<AccountId, Account>>, + generator: Mutex<Generator>, +} + +#[async_trait] +impl AccountRepository for InMemoryAccountRepository { + async fn create(&self, new: NewAccount) -> Result<Account, RepoError> { + 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<Option<Account>, RepoError> { + Ok(self.accounts.lock().unwrap().get(&id).cloned()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn new_account( + account_type: AccountType, + name: &str, + parent: Option<AccountId>, + ) -> 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<Account> { + 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<AccountId> = 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(_)))); + } +} |
