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, pub postings: Vec, pub description: String } pub struct NewJournalEntry { pub date: DateTime, pub postings: Vec, pub description: String } impl NewJournalEntry { fn new(date: DateTime, postings: Vec, description: impl Into) -> 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) -> 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)) )); } }