summaryrefslogtreecommitdiff
path: root/src/types/journal.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/types/journal.rs')
-rw-r--r--src/types/journal.rs208
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))
+ ));
+ }
+}