summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPROINT <>2026-07-22 14:41:42 +0700
committerPROINT <>2026-07-22 14:41:42 +0700
commit9cb86b850699dc5b19e2582f5a2c64c5acc0a24b (patch)
tree8ea8b9392646efd2b000c4064d72b96572de77ee
parent2fb9ff9efbafa582ca7e83f12c61548d8e7a93ed (diff)
Added AccountRepository trait
-rw-r--r--src/types/account.rs46
-rw-r--r--src/types/ids.rs43
-rw-r--r--src/types/journal.rs208
-rw-r--r--src/types/mod.rs11
-rw-r--r--src/types/path.rs37
-rw-r--r--src/types/repository.rs171
6 files changed, 516 insertions, 0 deletions
diff --git a/src/types/account.rs b/src/types/account.rs
new file mode 100644
index 0000000..66796a6
--- /dev/null
+++ b/src/types/account.rs
@@ -0,0 +1,46 @@
+use super::ids::AccountId;
+use super::path::MaterializedPath;
+
+#[derive(PartialEq, Clone)]
+pub enum AccountType {
+ Asset,
+ Liability,
+ Equity,
+ Revenue,
+ Expense
+}
+
+#[derive(Clone)]
+pub struct Account {
+ account_id: AccountId,
+ pub account_type: AccountType,
+ pub name: String,
+ pub description: String,
+ pub path: MaterializedPath
+}
+
+impl Account {
+ pub(crate) fn from_parts(account_id: AccountId, account_type: AccountType, name: String, description: String, path: MaterializedPath) -> Self {
+ Account { account_id, account_type, name, description, path }
+ }
+
+ pub fn id(&self) -> AccountId { self.account_id }
+}
+
+pub struct NewAccount {
+ pub account_type: AccountType,
+ pub name: String,
+ pub description: String,
+ pub parent: Option<AccountId>
+}
+
+impl NewAccount {
+ fn new(account_type: AccountType, name: impl Into<String>, description: impl Into<String>, parent: Option<AccountId>) -> NewAccount {
+ NewAccount {
+ account_type,
+ name: name.into(),
+ description: description.into(),
+ parent
+ }
+ }
+}
diff --git a/src/types/ids.rs b/src/types/ids.rs
new file mode 100644
index 0000000..03ebb59
--- /dev/null
+++ b/src/types/ids.rs
@@ -0,0 +1,43 @@
+use ulid::Ulid;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
+pub struct AccountId(Ulid);
+
+impl AccountId {
+ pub(crate) fn from_ulid(ulid: Ulid) -> Self {
+ AccountId(ulid)
+ }
+ pub fn as_ulid(&self) -> Ulid {
+ self.0
+ }
+}
+
+impl std::fmt::Display for AccountId {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "{}", self.0)
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
+pub struct PostingId(Ulid);
+
+impl PostingId {
+ pub(crate) fn from_ulid(ulid: Ulid) -> Self {
+ PostingId(ulid)
+ }
+ pub fn as_ulid(&self) -> Ulid {
+ self.0
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
+pub struct JournalEntryId(Ulid);
+
+impl JournalEntryId {
+ pub(crate) fn from_ulid(ulid: Ulid) -> Self {
+ JournalEntryId(ulid)
+ }
+ pub fn as_ulid(&self) -> Ulid {
+ self.0
+ }
+}
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))
+ ));
+ }
+}
diff --git a/src/types/mod.rs b/src/types/mod.rs
new file mode 100644
index 0000000..cb89841
--- /dev/null
+++ b/src/types/mod.rs
@@ -0,0 +1,11 @@
+mod account;
+mod ids;
+mod journal;
+mod path;
+mod repository;
+
+pub use account::*;
+pub use ids::*;
+pub use journal::*;
+pub use path::*;
+pub use repository::*;
diff --git a/src/types/path.rs b/src/types/path.rs
new file mode 100644
index 0000000..1d095cd
--- /dev/null
+++ b/src/types/path.rs
@@ -0,0 +1,37 @@
+use ulid::Ulid;
+
+use super::ids::AccountId;
+
+#[derive(Debug, thiserror::Error)]
+pub enum PathError {
+ #[error("path is empty")]
+ Empty,
+ #[error("invalid ULID segment: {0}")]
+ BadSegment(String),
+}
+
+#[derive(Clone)]
+pub struct MaterializedPath(String);
+
+impl MaterializedPath {
+ pub fn parse(s: &str) -> Result<Self, PathError> {
+ if s.is_empty() {
+ return Err(PathError::Empty);
+ }
+ for seg in s.split('/') {
+ Ulid::from_string(seg).map_err(|_| PathError::BadSegment(seg.to_string()))?;
+ }
+ Ok(MaterializedPath(s.to_string()))
+ }
+ pub fn as_str(&self) -> &str { &self.0 }
+ pub fn depth(&self) -> usize { self.0.split('/').count() }
+ pub fn is_ancestor_of(&self, other: &MaterializedPath) -> bool {
+ other.0.starts_with(&self.0) && other.0.as_bytes().get(self.0.len()) == Some(&b'/')
+ }
+ pub fn with_child(&self, id: AccountId) -> MaterializedPath {
+ MaterializedPath(format!("{}/{}", self.0, id.as_ulid()))
+ }
+ pub fn root(id: AccountId) -> MaterializedPath {
+ MaterializedPath(id.as_ulid().to_string())
+ }
+}
diff --git a/src/types/repository.rs b/src/types/repository.rs
new file mode 100644
index 0000000..0bef30e
--- /dev/null
+++ b/src/types/repository.rs
@@ -0,0 +1,171 @@
+use async_trait::async_trait;
+use std::collections::HashMap;
+use std::sync::Mutex;
+use ulid::Generator;
+
+use super::*;
+
+#[derive(Debug, thiserror::Error)]
+pub enum RepoError {
+ #[error("parent account {0} not found")]
+ ParentNotFound(AccountId),
+}
+
+#[async_trait]
+pub trait AccountRepository: Send + Sync {
+ async fn create(&self, new: NewAccount) -> Result<Account, RepoError>;
+ async fn get(&self, id: AccountId) -> Result<Option<Account>, RepoError>;
+}
+
+#[derive(Default)]
+pub struct InMemoryAccountRepository {
+ accounts: Mutex<HashMap<AccountId, Account>>,
+ generator: Mutex<Generator>,
+}
+
+#[async_trait]
+impl AccountRepository for InMemoryAccountRepository {
+ async fn create(&self, new: NewAccount) -> Result<Account, RepoError> {
+ let id = {
+ let mut generator = self.generator.lock().unwrap();
+ // generate() is monotonic; on the rare same-tick random overflow,
+ // recover into a valid (incremented) Ulid rather than failing.
+ let ulid = generator
+ .generate()
+ .unwrap_or_else(|overflow| overflow.commit_overflow_increment());
+ AccountId::from_ulid(ulid)
+ };
+
+ let mut accounts = self.accounts.lock().unwrap();
+
+ let path = match new.parent {
+ Some(parent_id) => accounts
+ .get(&parent_id)
+ .ok_or(RepoError::ParentNotFound(parent_id))?
+ .path
+ .with_child(id),
+ None => MaterializedPath::root(id),
+ };
+
+ let account =
+ Account::from_parts(id, new.account_type, new.name, new.description, path);
+ accounts.insert(id, account.clone());
+ Ok(account)
+ }
+
+ async fn get(&self, id: AccountId) -> Result<Option<Account>, RepoError> {
+ Ok(self.accounts.lock().unwrap().get(&id).cloned())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn new_account(
+ account_type: AccountType,
+ name: &str,
+ parent: Option<AccountId>,
+ ) -> NewAccount {
+ NewAccount {
+ account_type,
+ name: name.into(),
+ description: String::new(),
+ parent,
+ }
+ }
+
+ // Seed the five standard chart-of-accounts roots, each with no parent.
+ async fn seed_roots(repo: &InMemoryAccountRepository) -> Vec<Account> {
+ let roots = [
+ (AccountType::Asset, "Assets"),
+ (AccountType::Liability, "Liabilities"),
+ (AccountType::Equity, "Equity"),
+ (AccountType::Revenue, "Revenue"),
+ (AccountType::Expense, "Expenses"),
+ ];
+ let mut created = Vec::new();
+ for (account_type, name) in roots {
+ let account = repo
+ .create(new_account(account_type, name, None))
+ .await
+ .unwrap();
+ created.push(account);
+ }
+ created
+ }
+
+ #[tokio::test]
+ async fn seeds_five_distinct_root_accounts() {
+ let repo = InMemoryAccountRepository::default();
+ let roots = seed_roots(&repo).await;
+
+ assert_eq!(roots.len(), 5);
+
+ // each root's path is just its own id — depth 1, no parent segment
+ for root in &roots {
+ assert_eq!(root.path.depth(), 1);
+ }
+
+ // all five ids are distinct
+ let mut ids: Vec<AccountId> = roots.iter().map(|a| a.id()).collect();
+ ids.sort();
+ ids.dedup();
+ assert_eq!(ids.len(), 5);
+ }
+
+ #[tokio::test]
+ async fn child_nests_under_its_parent() {
+ let repo = InMemoryAccountRepository::default();
+ let roots = seed_roots(&repo).await;
+ let assets = &roots[0];
+
+ let cash = repo
+ .create(new_account(AccountType::Asset, "Cash", Some(assets.id())))
+ .await
+ .unwrap();
+
+ // the child's path was built from the parent's
+ assert!(assets.path.is_ancestor_of(&cash.path));
+ assert_eq!(cash.path.depth(), assets.path.depth() + 1);
+
+ // and a grandchild nests under both
+ let checking = repo
+ .create(new_account(AccountType::Asset, "Checking", Some(cash.id())))
+ .await
+ .unwrap();
+
+ assert!(assets.path.is_ancestor_of(&checking.path));
+ assert!(cash.path.is_ancestor_of(&checking.path));
+ assert_eq!(checking.path.depth(), 3);
+ }
+
+ #[tokio::test]
+ async fn get_returns_a_created_account() {
+ let repo = InMemoryAccountRepository::default();
+ let roots = seed_roots(&repo).await;
+ let assets = &roots[0];
+
+ let fetched = repo.get(assets.id()).await.unwrap();
+ assert_eq!(fetched.map(|a| a.id()), Some(assets.id()));
+ }
+
+ #[tokio::test]
+ async fn create_under_missing_parent_fails() {
+ let repo = InMemoryAccountRepository::default();
+ seed_roots(&repo).await;
+
+ // an id minted by a *different* repo is guaranteed absent from `repo`
+ let other = InMemoryAccountRepository::default();
+ let stranger = other
+ .create(new_account(AccountType::Asset, "Elsewhere", None))
+ .await
+ .unwrap();
+
+ let result = repo
+ .create(new_account(AccountType::Asset, "Orphan", Some(stranger.id())))
+ .await;
+
+ assert!(matches!(result, Err(RepoError::ParentNotFound(_))));
+ }
+}