diff options
Diffstat (limited to 'src/types/journal.rs')
| -rw-r--r-- | src/types/journal.rs | 208 |
1 files changed, 208 insertions, 0 deletions
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)) + )); + } +} |
