summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPROINT <>2026-07-22 17:03:47 +0700
committerPROINT <>2026-07-22 17:03:47 +0700
commitccb6d208204fb286e469e87ad9c85a8c8b7be72a (patch)
tree5742b78074d4227d7c83fb1ad73c522e30af69a2
parent9cb86b850699dc5b19e2582f5a2c64c5acc0a24b (diff)
Added JournalEntryRepositoryHEADmaster
-rw-r--r--Cargo.lock13
-rw-r--r--Cargo.toml4
-rw-r--r--src/main.rs196
-rw-r--r--src/types.rs113
-rw-r--r--src/types/journal.rs20
-rw-r--r--src/types/repository.rs192
6 files changed, 419 insertions, 119 deletions
diff --git a/Cargo.lock b/Cargo.lock
index ca293bb..d92b55c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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",
]
diff --git a/Cargo.toml b/Cargo.toml
index 59cf92b..feb0396 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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(_)))
+ ));
+ }
}