diff options
| -rw-r--r-- | Cargo.lock | 13 | ||||
| -rw-r--r-- | Cargo.toml | 4 | ||||
| -rw-r--r-- | src/main.rs | 196 | ||||
| -rw-r--r-- | src/types.rs | 113 | ||||
| -rw-r--r-- | src/types/journal.rs | 20 | ||||
| -rw-r--r-- | src/types/repository.rs | 192 |
6 files changed, 419 insertions, 119 deletions
@@ -24,6 +24,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + +[[package]] name = "atoi" version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -464,9 +475,11 @@ name = "ggb" version = "0.1.0" dependencies = [ "anyhow", + "async-trait", "chrono", "serde", "sqlx", + "thiserror", "tokio", "ulid", ] @@ -5,8 +5,10 @@ edition = "2024" [dependencies] anyhow = "1.0.104" +async-trait = "0.1.91" chrono = "0.4.45" serde = { version = "1.0.229", features = ["derive"] } sqlx = { version = "0.9.0", features = ["postgres", "runtime-tokio", "tls-rustls"] } +thiserror = "2.0.19" tokio = { version = "1.53.0", features = ["full"] } -ulid = { version = "3.0.0", features = ["postgres", "serde"] } +ulid = { version = "3.0.0", features = ["postgres", "serde", "std"] } diff --git a/src/main.rs b/src/main.rs index bfc2330..831c0dd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,199 @@ mod types; +use std::collections::HashMap; +use std::sync::Arc; + +use chrono::Utc; use types::*; -fn main() { - println!("Hello World!"); +#[tokio::main(flavor = "current_thread")] +async fn main() { + let account_repo = Arc::new(InMemoryAccountRepository::default()); + + // Seed a few accounts. + let cash = account_repo + .create(NewAccount { + account_type: AccountType::Asset, + name: "Cash".into(), + description: String::new(), + parent: None, + }) + .await + .unwrap(); + let revenue = account_repo + .create(NewAccount { + account_type: AccountType::Revenue, + name: "Sales Revenue".into(), + description: String::new(), + parent: None, + }) + .await + .unwrap(); + let rent = account_repo + .create(NewAccount { + account_type: AccountType::Expense, + name: "Rent Expense".into(), + description: String::new(), + parent: None, + }) + .await + .unwrap(); + + // Post entries through the repository — it validates and checks accounts exist. + let journal_repo = InMemoryJournalEntryRepository::new(account_repo.clone()); + + let sale = journal_repo + .create(NewJournalEntry { + date: Utc::now(), + description: "Cash sale".into(), + postings: vec![ + NewPosting { + posting_type: PostingType::Debit, + account_id: cash.id(), + amount: 500_000, + }, + NewPosting { + posting_type: PostingType::Credit, + account_id: revenue.id(), + amount: 500_000, + }, + ], + }) + .await + .unwrap(); + + let rent_payment = journal_repo + .create(NewJournalEntry { + date: Utc::now(), + description: "Pay office rent".into(), + postings: vec![ + NewPosting { + posting_type: PostingType::Debit, + account_id: rent.id(), + amount: 200_000, + }, + NewPosting { + posting_type: PostingType::Credit, + account_id: cash.id(), + amount: 200_000, + }, + ], + }) + .await + .unwrap(); + + let entries = vec![sale, rent_payment]; + let accounts = vec![cash, revenue, rent]; + print_journal(&entries, &accounts); + println!(); + print_ledger(&entries, &accounts); +} + +fn account_type_name(t: &AccountType) -> &'static str { + match t { + AccountType::Asset => "Asset", + AccountType::Liability => "Liability", + AccountType::Equity => "Equity", + AccountType::Revenue => "Revenue", + AccountType::Expense => "Expense", + } +} + +/// Format a whole-won amount with thousands separators. +fn group(n: u64) -> String { + let s = n.to_string(); + let b = s.as_bytes(); + let mut out = String::new(); + for (i, c) in b.iter().enumerate() { + if i > 0 && (b.len() - i) % 3 == 0 { + out.push(','); + } + out.push(*c as char); + } + out +} + +fn signed(balance: i128) -> String { + if balance < 0 { + format!("-{}", group((-balance) as u64)) + } else { + group(balance as u64) + } +} + +fn print_journal(entries: &[JournalEntry], accounts: &[Account]) { + let by_id: HashMap<AccountId, &Account> = accounts.iter().map(|a| (a.id(), a)).collect(); + + println!("JOURNAL"); + println!("{}", "-".repeat(52)); + for entry in entries { + println!("{} {}", entry.date.format("%Y-%m-%d"), entry.description); + for p in &entry.postings { + let name = by_id + .get(&p.account_id) + .map(|a| a.name.as_str()) + .unwrap_or("<unknown>"); + let (dr, cr) = match p.posting_type { + PostingType::Debit => (group(p.amount), String::new()), + PostingType::Credit => (String::new(), group(p.amount)), + }; + println!(" {:<18} {:>12} {:>12}", name, dr, cr); + } + } +} + +fn print_ledger(entries: &[JournalEntry], accounts: &[Account]) { + // Accumulate debit/credit totals per account. + let mut totals: HashMap<AccountId, (u64, u64)> = HashMap::new(); + for entry in entries { + for p in &entry.postings { + let e = totals.entry(p.account_id).or_insert((0, 0)); + match p.posting_type { + PostingType::Debit => e.0 += p.amount, + PostingType::Credit => e.1 += p.amount, + } + } + } + + println!("LEDGER (KRW)"); + println!("{}", "-".repeat(72)); + println!( + "{:<18} {:<10} {:>12} {:>12} {:>14}", + "Account", "Type", "Debits", "Credits", "Balance" + ); + println!("{}", "-".repeat(72)); + + let (mut total_dr, mut total_cr) = (0u64, 0u64); + for account in accounts { + let (dr, cr) = totals.get(&account.id()).copied().unwrap_or((0, 0)); + total_dr += dr; + total_cr += cr; + // Balance shown on the account's normal side (debit-normal for Asset/Expense). + let balance: i128 = match &account.account_type { + AccountType::Asset | AccountType::Expense => dr as i128 - cr as i128, + _ => cr as i128 - dr as i128, + }; + println!( + "{:<18} {:<10} {:>12} {:>12} {:>14}", + account.name, + account_type_name(&account.account_type), + group(dr), + group(cr), + signed(balance), + ); + } + + println!("{}", "-".repeat(72)); + println!( + "{:<18} {:<10} {:>12} {:>12}", + "TOTAL", + "", + group(total_dr), + group(total_cr) + ); + if total_dr == total_cr { + println!("\nBooks balance: debits == credits ({} KRW)", group(total_dr)); + } else { + println!("\nWARNING: books do not balance"); + } } diff --git a/src/types.rs b/src/types.rs deleted file mode 100644 index 98af390..0000000 --- a/src/types.rs +++ /dev/null @@ -1,113 +0,0 @@ -use chrono::{DateTime, Utc}; -use ulid::Ulid; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub struct AccountId(Ulid); - -impl AccountId { - pub fn as_ulid(&self) -> Ulid { - self.0 - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub struct PostingId(Ulid); - -impl PostingId { - pub fn as_ulid(&self) -> Ulid { - self.0 - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub struct JournalEntryId(Ulid); - -impl JournalEntryId { - pub fn as_ulid(&self) -> Ulid { - self.0 - } -} - -#[derive(PartialEq)] -pub enum AccountType { - Asset, - Liability, - Equity, - Revenue, - Expense -} - -pub struct Account { - account_id: AccountId, - pub account_type: AccountType, - pub name: String, - pub description: String -} - -pub struct NewAccount { - pub account_type: AccountType, - pub name: String, - pub description: String -} - -impl NewAccount { - fn new(account_type: AccountType, name: impl Into<String>, description: impl Into<String>) -> NewAccount { - NewAccount { - account_type, - name: name.into(), - description: description.into() - } - } -} - -#[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() - } - } -} 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(_))) + )); + } } |
