summaryrefslogtreecommitdiff
path: root/src/types/repository.rs
diff options
context:
space:
mode:
authorPROINT <>2026-07-22 14:41:42 +0700
committerPROINT <>2026-07-22 14:41:42 +0700
commit9cb86b850699dc5b19e2582f5a2c64c5acc0a24b (patch)
tree8ea8b9392646efd2b000c4064d72b96572de77ee /src/types/repository.rs
parent2fb9ff9efbafa582ca7e83f12c61548d8e7a93ed (diff)
Added AccountRepository trait
Diffstat (limited to 'src/types/repository.rs')
-rw-r--r--src/types/repository.rs171
1 files changed, 171 insertions, 0 deletions
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(_))));
+ }
+}