use aamos_core::{Currency, DecisionSource, Money, Period, TenantId}; use chrono::{DateTime, NaiveDate, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; // ── Konto ───────────────────────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Account { pub id: Uuid, pub tenant_id: TenantId, pub account_number: String, // t.ex. "1910", "3000" pub name: String, pub account_type: AccountType, pub normal_balance: BalanceSide, pub coa_standard: CoaStandard, pub parent_account: Option, pub vat_code: Option, pub is_active: bool, pub metadata: serde_json::Value, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum AccountType { Asset, Liability, Equity, Revenue, Expense, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum BalanceSide { Debit, Credit, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum CoaStandard { BAS, IFRS, USGAAP, Custom, } // ── Verifikation ────────────────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] pub struct JournalEntry { pub id: Uuid, pub tenant_id: TenantId, pub entry_number: Option, // löpnummer per tenant+räkenskapsår pub fiscal_year: i32, pub period: Period, pub entry_date: NaiveDate, pub description: String, pub reference: Option, pub source_type: SourceType, pub source_id: Option, pub status: EntryStatus, pub void_reason: Option, pub voided_at: Option>, pub voided_by: Option, pub trace_id: Uuid, pub correlation_id: Uuid, pub user_id: String, pub decision_source: DecisionSource, pub metadata: serde_json::Value, pub created_at: DateTime, pub posted_at: Option>, pub lines: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum SourceType { Manual, ImportSie, ImportCsv, Bank, System, Agent, OpeningBalance, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum EntryStatus { Draft, Posted, Voided, } // ── Verifikationsrad ────────────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] pub struct JournalLine { pub id: Uuid, pub entry_id: Uuid, pub tenant_id: TenantId, pub line_number: i32, pub account_number: String, pub account_name: Option, pub debit: Option, pub credit: Option, pub currency: Currency, pub amount_base: Option, pub vat_code: Option, pub vat_amount: Option, pub cost_center: Option, pub project_code: Option, pub description: Option, pub metadata: serde_json::Value, } // ── Period ──────────────────────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LedgerPeriod { pub id: Uuid, pub tenant_id: TenantId, pub fiscal_year: i32, pub period: Period, pub status: PeriodStatus, pub opened_at: DateTime, pub closed_at: Option>, pub closed_by: Option, pub workflow_id: Option, pub trial_balance: Option, pub trace_id: Uuid, pub metadata: serde_json::Value, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum PeriodStatus { Open, Review, Approved, Closed, } // ── Validering ──────────────────────────────────────────────────────────── impl JournalEntry { /// Validerar att verifikationen är balanserad (summa debet = summa kredit). pub fn is_balanced(&self) -> bool { let total_debit: Money = self .lines .iter() .filter_map(|l| l.debit) .fold(Money::zero(), |a, b| a + b); let total_credit: Money = self .lines .iter() .filter_map(|l| l.credit) .fold(Money::zero(), |a, b| a + b); total_debit == total_credit } /// Validerar att alla rader har antingen debet ELLER kredit (inte båda, inte inget). pub fn validate_lines(&self) -> Result<(), String> { for (i, line) in self.lines.iter().enumerate() { match (&line.debit, &line.credit) { (Some(_), Some(_)) => { return Err(format!("Line {} has both debit and credit", i + 1)) } (None, None) => { return Err(format!("Line {} has neither debit nor credit", i + 1)) } _ => {} } } Ok(()) } } #[cfg(test)] mod tests { use super::*; fn test_tenant() -> TenantId { TenantId::new("test-tenant") } fn base_entry() -> JournalEntry { JournalEntry { id: Uuid::new_v4(), tenant_id: test_tenant(), entry_number: Some(1), fiscal_year: 2026, period: Period::new(2026, 1).unwrap(), entry_date: NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(), description: "Test entry".to_string(), reference: None, source_type: SourceType::Manual, source_id: None, status: EntryStatus::Draft, void_reason: None, voided_at: None, voided_by: None, trace_id: Uuid::new_v4(), correlation_id: Uuid::new_v4(), user_id: "test-user".to_string(), decision_source: DecisionSource::User, metadata: serde_json::Value::Object(serde_json::Map::new()), created_at: Utc::now(), posted_at: None, lines: vec![], } } fn make_line(debit: Option, credit: Option) -> JournalLine { JournalLine { id: Uuid::new_v4(), entry_id: Uuid::new_v4(), tenant_id: test_tenant(), line_number: 1, account_number: "1910".to_string(), account_name: None, debit: debit.map(Money), credit: credit.map(Money), currency: Currency::SEK, amount_base: None, vat_code: None, vat_amount: None, cost_center: None, project_code: None, description: None, metadata: serde_json::Value::Object(serde_json::Map::new()), } } #[test] fn test_journal_entry_balanced() { let mut entry = base_entry(); entry.lines = vec![ make_line(Some(10000), None), // debit 10 000 make_line(None, Some(10000)), // credit 10 000 ]; assert!(entry.is_balanced()); } #[test] fn test_journal_entry_unbalanced() { let mut entry = base_entry(); entry.lines = vec![ make_line(Some(10000), None), // debit 10 000 make_line(None, Some(5000)), // credit 5 000 ]; assert!(!entry.is_balanced()); } #[test] fn test_validate_lines_ok() { let mut entry = base_entry(); entry.lines = vec![ make_line(Some(10000), None), make_line(None, Some(10000)), ]; assert!(entry.validate_lines().is_ok()); } #[test] fn test_validate_lines_both_debit_and_credit() { let mut entry = base_entry(); entry.lines = vec![ make_line(Some(10000), Some(10000)), ]; let result = entry.validate_lines(); assert!(result.is_err()); assert!(result.unwrap_err().contains("both debit and credit")); } #[test] fn test_validate_lines_neither() { let mut entry = base_entry(); entry.lines = vec![ make_line(None, None), ]; let result = entry.validate_lines(); assert!(result.is_err()); assert!(result.unwrap_err().contains("neither debit nor credit")); } #[test] fn test_period_new_valid() { let p = Period::new(2026, 6).unwrap(); assert_eq!(p.year, 2026); assert_eq!(p.month, 6); } #[test] fn test_period_new_invalid_month() { assert!(Period::new(2026, 0).is_err()); assert!(Period::new(2026, 13).is_err()); } #[test] fn test_period_from_str() { let p = Period::from_str("2026-03").unwrap(); assert_eq!(p.year, 2026); assert_eq!(p.month, 3); } #[test] fn test_money_operations() { let a = Money(1000); let b = Money(500); assert_eq!((a + b).0, 1500); assert_eq!((a - b).0, 500); assert_eq!((-a).0, -1000); } #[test] fn test_money_from_decimal() { let m = Money::from_decimal(123.45); assert_eq!(m.0, 12345); } #[test] fn test_money_to_decimal() { let m = Money(12345); assert!((m.to_decimal() - 123.45).abs() < 0.001); } }