use aamos_core::{Currency, DecisionSource, LedgerError, LedgerResult, Money, Period, TenantId}; use aamos_ledger::{EntryStatus, JournalEntry, JournalLine, SourceType}; use chrono::{Datelike, NaiveDate}; use std::io::{BufRead, BufReader, Read}; use uuid::Uuid; /// Parse SIE4 format into JournalEntry structs. /// /// SIE4 is a Swedish accounting data exchange format. Each record starts with /// a `#TAG` followed by fields enclosed in curly braces `{}`. /// /// Relevant tags for import: /// - #FLAGGA — file flag (0 = normal) /// - #FORMAT — encoding (PC8/PC850/UTF8) /// - #SIETYP — SIE type (4 = SIE4) /// - #FNR — company number /// - #FNAMN — company name /// - #ORGNR — organization number /// - #KPTYP — chart of accounts type (BAS96, BAS97, EUBAS97) /// - #RAR — fiscal year (0 = current, -1 = previous, etc.) /// - #KONTO — account definition (#KONTO {account_number} {account_name}) /// - #VER — voucher/verifikation (#VER {series} {entry_number} {date} {description} {registration_date} {registered_by}) /// - #TRANS — transaction line within a #VER block (#TRANS {account} {amount} {date} {description} {quantity}) /// - #PBUDGET — periodic budget /// - #PSALDO — periodic balance /// - #SRU — SRU code mapping /// - #DIM — dimension definition /// - #UNDERDIM — sub-dimension /// - #OBJ — object within dimension pub fn parse_entries(reader: R) -> LedgerResult> { let buf_reader = BufReader::new(reader); let mut lines = Vec::new(); let mut current_ver: Option = None; let mut entries: Vec = Vec::new(); let mut line_no = 0usize; for raw_line in buf_reader.lines() { line_no += 1; let raw_line = raw_line.map_err(|e| { LedgerError::BadRequest(format!("SIE4 read error at line {}: {}", line_no, e)) })?; let line = raw_line.trim(); if line.is_empty() || line.starts_with("//") { continue; } // Normalize: SIE4 uses CP437 or UTF-8; we assume UTF-8 here. lines.push(line.to_string()); if line.starts_with("#VER ") { // Flush previous #VER if any if let Some(ver) = current_ver.take() { entries.push(ver.build()?); } current_ver = Some(parse_ver_line(line, line_no)?); } else if line.starts_with("#TRANS ") { let ver = current_ver .as_mut() .ok_or_else(|| LedgerError::Validation(format!( "SIE4: #TRANS without #VER at line {}", line_no )))?; ver.trans.push(parse_trans_line(line, line_no)?); } else if line.starts_with("#RTRANS ") { // RTRANS = reversed transaction (handled same as TRANS with negated amount) let ver = current_ver .as_mut() .ok_or_else(|| LedgerError::Validation(format!( "SIE4: #RTRANS without #VER at line {}", line_no )))?; ver.trans.push(parse_rtrans_line(line, line_no)?); } else if line.starts_with("#BTRANS ") { // BTRANS = budget transaction (skip for journal import) // Budget transactions are not part of the actual journal. } else if line == "}" { // End of #VER block (optional in some SIE4 files) if let Some(ver) = current_ver.take() { entries.push(ver.build()?); } } // Other tags are ignored for journal-entry import } // Flush final #VER if let Some(ver) = current_ver.take() { entries.push(ver.build()?); } Ok(entries) } // ── Internal builders ───────────────────────────────────────────────────── struct VerBuilder { series: String, entry_number: String, date: NaiveDate, description: String, _reg_date: Option, reg_by: Option, trans: Vec, } struct TransLine { account: String, amount: f64, // in SEK, positive = debit, negative = credit _date: Option, description: Option, _quantity: Option, cost_center: Option, project: Option, } impl VerBuilder { fn build(self) -> LedgerResult { let entry_number = self.entry_number.parse::().ok(); let fiscal_year = self.date.year(); let month = self.date.month() as u8; let period = Period::new(fiscal_year, month)?; let trace_id = Uuid::new_v4(); let correlation_id = Uuid::new_v4(); let mut lines: Vec = Vec::with_capacity(self.trans.len()); for (idx, t) in self.trans.iter().enumerate() { let (debit, credit) = if t.amount >= 0.0 { (Some(Money::from_decimal(t.amount)), None) } else { (None, Some(Money::from_decimal(-t.amount))) }; lines.push(JournalLine { id: Uuid::new_v4(), entry_id: Uuid::nil(), // filled later by storage layer tenant_id: TenantId::new("imported"), line_number: (idx + 1) as i32, account_number: t.account.clone(), account_name: None, debit, credit, currency: Currency::SEK, amount_base: None, vat_code: None, vat_amount: None, cost_center: t.cost_center.clone(), project_code: t.project.clone(), description: t.description.clone(), metadata: serde_json::Value::Object(serde_json::Map::new()), }); } Ok(JournalEntry { id: Uuid::new_v4(), tenant_id: TenantId::new("imported"), entry_number, fiscal_year, period, entry_date: self.date, description: self.description, reference: Some(format!("{}-{}", self.series, self.entry_number)), source_type: SourceType::ImportSie, source_id: None, status: EntryStatus::Draft, void_reason: None, voided_at: None, voided_by: None, trace_id, correlation_id, user_id: self.reg_by.unwrap_or_else(|| "sie-import".to_string()), decision_source: DecisionSource::System, metadata: serde_json::Value::Object(serde_json::Map::new()), created_at: chrono::Utc::now(), posted_at: None, lines, }) } } // ── Line parsers ────────────────────────────────────────────────────────── /// Parse a #VER line. /// /// Format: #VER {series} {entry_number} {date} {description} {registration_date} {registered_by} /// /// Example: #VER A 1 20260115 "Faktura #1234" 20260115 "admin" fn parse_ver_line(line: &str, line_no: usize) -> LedgerResult { let content = line.strip_prefix("#VER ").unwrap_or("").trim(); let tokens = tokenize_sie_line(content); if tokens.len() < 4 { return Err(LedgerError::Validation(format!( "SIE4: #VER too few fields at line {}: expected at least 4, got {}", line_no, tokens.len() ))); } let series = unquote(&tokens[0]); let entry_number = unquote(&tokens[1]); let date = parse_sie_date(&tokens[2], line_no)?; let description = unquote(&tokens[3]); let _reg_date = tokens.get(4).and_then(|d| parse_sie_date(d, line_no).ok()); let reg_by = tokens.get(5).map(|s| unquote(s)); Ok(VerBuilder { series, entry_number, date, description, _reg_date, reg_by, trans: Vec::new(), }) } /// Parse a #TRANS line. /// /// Format: #TRANS {account} {amount} {date} {description} {quantity} {sign} {dimension/object...} /// /// Example: #TRANS 1510 {1250.50} 20260115 "Kundfordran" /// /// Amount is enclosed in {} and may be negative (credit). fn parse_trans_line(line: &str, line_no: usize) -> LedgerResult { let content = line.strip_prefix("#TRANS ").unwrap_or("").trim(); let tokens = tokenize_sie_line(content); if tokens.len() < 2 { return Err(LedgerError::Validation(format!( "SIE4: #TRANS too few fields at line {}: expected at least 2, got {}", line_no, tokens.len() ))); } let account = unquote(&tokens[0]); let amount = parse_sie_amount(&tokens[1], line_no)?; let date = tokens.get(2).and_then(|d| parse_sie_date(d, line_no).ok()); let description = tokens.get(3).map(|s| unquote(s)); let quantity = tokens.get(4).and_then(|q| parse_sie_amount(q, line_no).ok()); // Parse dimension/object pairs if present (e.g. "1" "{cc}" "6" "{proj}") let mut cost_center = None; let mut project = None; let mut i = 6usize; while i + 1 < tokens.len() { let dim = unquote(&tokens[i]); let obj = unquote(&tokens[i + 1]); match dim.as_str() { "1" => cost_center = Some(obj), "6" => project = Some(obj), _ => {} } i += 2; } Ok(TransLine { account, amount, _date: date, description, _quantity: quantity, cost_center, project, }) } /// Parse a #RTRANS line (reversed transaction). /// Same as #TRANS but amount is negated. fn parse_rtrans_line(line: &str, line_no: usize) -> LedgerResult { let mut trans = parse_trans_line(line.replacen("#RTRANS ", "#TRANS ", 1).as_str(), line_no)?; trans.amount = -trans.amount; Ok(trans) } // ── Tokenizer ───────────────────────────────────────────────────────────── /// Tokenize a SIE4 line respecting quoted strings and braced amounts. /// /// SIE4 fields are separated by spaces, but: /// - Quoted strings `"..."` are treated as single tokens (quotes removed by unquote) /// - Braced amounts `{...}` are treated as single tokens /// - Bare tokens are passed through fn tokenize_sie_line(line: &str) -> Vec { let mut tokens = Vec::new(); let mut chars = line.chars().peekable(); let mut current = String::new(); let mut in_quotes = false; let mut in_braces = false; while let Some(ch) = chars.next() { if in_quotes { if ch == '"' { in_quotes = false; tokens.push(current.clone()); current.clear(); } else { current.push(ch); } } else if in_braces { if ch == '}' { in_braces = false; tokens.push(current.clone()); current.clear(); } else { current.push(ch); } } else { match ch { '"' => { if !current.is_empty() { tokens.push(current.clone()); current.clear(); } in_quotes = true; } '{' => { if !current.is_empty() { tokens.push(current.clone()); current.clear(); } in_braces = true; } ' ' | '\t' => { if !current.is_empty() { tokens.push(current.clone()); current.clear(); } } _ => current.push(ch), } } } if !current.is_empty() { tokens.push(current); } tokens } // ── Helpers ─────────────────────────────────────────────────────────────── fn unquote(s: &str) -> String { s.trim_matches('"').to_string() } /// Parse SIE4 date: YYYYMMDD fn parse_sie_date(s: &str, line_no: usize) -> LedgerResult { let s = unquote(s); if s.len() != 8 { return Err(LedgerError::Validation(format!( "SIE4: invalid date '{}' at line {}, expected YYYYMMDD", s, line_no ))); } let year = s[0..4].parse::().map_err(|_| { LedgerError::Validation(format!("SIE4: invalid year in date '{}' at line {}", s, line_no)) })?; let month = s[4..6].parse::().map_err(|_| { LedgerError::Validation(format!("SIE4: invalid month in date '{}' at line {}", s, line_no)) })?; let day = s[6..8].parse::().map_err(|_| { LedgerError::Validation(format!("SIE4: invalid day in date '{}' at line {}", s, line_no)) })?; NaiveDate::from_ymd_opt(year, month, day).ok_or_else(|| { LedgerError::Validation(format!( "SIE4: invalid date '{}' at line {}", s, line_no )) }) } /// Parse SIE4 amount. Amounts may be in braces or bare. fn parse_sie_amount(s: &str, line_no: usize) -> LedgerResult { let s = unquote(s).trim_matches('{').trim_matches('}').replace(',', "."); s.parse::().map_err(|_| { LedgerError::Validation(format!( "SIE4: invalid amount '{}' at line {}", s, line_no )) }) } // ── Tests ───────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; #[test] fn test_tokenize_simple() { let tokens = tokenize_sie_line(r#"A 1 20260115 "Faktura #1234""#); assert_eq!(tokens, vec!["A", "1", "20260115", "Faktura #1234"]); } #[test] fn test_tokenize_braced_amount() { let tokens = tokenize_sie_line(r#"1510 {1250.50} 20260115 "Kundfordran""#); assert_eq!(tokens, vec!["1510", "1250.50", "20260115", "Kundfordran"]); } #[test] fn test_tokenize_negative_amount() { let tokens = tokenize_sie_line(r#"3001 {-1000.00} 20260115 "Intäkt""#); assert_eq!(tokens, vec!["3001", "-1000.00", "20260115", "Intäkt"]); } #[test] fn test_parse_sie_date() { let d = parse_sie_date("20260115", 1).unwrap(); assert_eq!(d, NaiveDate::from_ymd_opt(2026, 1, 15).unwrap()); } #[test] fn test_parse_sie_amount() { let a = parse_sie_amount("{1250.50}", 1).unwrap(); assert!((a - 1250.50).abs() < 0.001); } #[test] fn test_parse_sie_amount_negative() { let a = parse_sie_amount("{-1000.00}", 1).unwrap(); assert!((a - (-1000.0)).abs() < 0.001); } #[test] fn test_parse_ver_line() { let ver = parse_ver_line(r#"#VER A 1 20260115 "Faktura #1234" 20260115 "admin""#, 1).unwrap(); assert_eq!(ver.series, "A"); assert_eq!(ver.entry_number, "1"); assert_eq!(ver.date, NaiveDate::from_ymd_opt(2026, 1, 15).unwrap()); assert_eq!(ver.description, "Faktura #1234"); assert_eq!(ver.reg_by, Some("admin".to_string())); } #[test] fn test_parse_trans_line_debit() { let trans = parse_trans_line(r#"#TRANS 1510 {1250.50} 20260115 "Kundfordran""#, 1).unwrap(); assert_eq!(trans.account, "1510"); assert!((trans.amount - 1250.50).abs() < 0.001); assert_eq!(trans._date, Some(NaiveDate::from_ymd_opt(2026, 1, 15).unwrap())); assert_eq!(trans.description, Some("Kundfordran".to_string())); } #[test] fn test_parse_trans_line_credit() { let trans = parse_trans_line(r#"#TRANS 3001 {-1000.00} 20260115 "Intäkt""#, 1).unwrap(); assert_eq!(trans.account, "3001"); assert!((trans.amount - (-1000.0)).abs() < 0.001); } #[test] fn test_parse_rtrans_line() { let trans = parse_rtrans_line(r#"#RTRANS 3001 {1000.00} 20260115 "Korrigering""#, 1).unwrap(); assert_eq!(trans.account, "3001"); assert!((trans.amount - (-1000.0)).abs() < 0.001); } #[test] fn test_parse_full_sie4() { let sie4_data = r#"#FLAGGA 0 #FORMAT PC8 #SIETYP 4 #FNR 1 #FNAMN "Testföretag AB" #ORGNR 5566778899 #KPTYP BAS2024 #VER A 1 20260115 "Faktura #1234" { #TRANS 1510 {125000.00} 20260115 "Kundfordran" #TRANS 3001 {-100000.00} 20260115 "Konsultintäkter" #TRANS 2611 {-25000.00} 20260115 "Utgående moms 25%" } "#; let entries = parse_entries(sie4_data.as_bytes()).unwrap(); assert_eq!(entries.len(), 1); let entry = &entries[0]; assert_eq!(entry.fiscal_year, 2026); assert_eq!(entry.period.to_string(), "2026-01"); assert_eq!(entry.entry_date, NaiveDate::from_ymd_opt(2026, 1, 15).unwrap()); assert_eq!(entry.description, "Faktura #1234"); assert_eq!(entry.lines.len(), 3); assert!(entry.is_balanced()); // Line 1: debit 125000 assert_eq!(entry.lines[0].account_number, "1510"); assert_eq!(entry.lines[0].debit, Some(Money(12500000))); assert_eq!(entry.lines[0].credit, None); // Line 2: credit 100000 assert_eq!(entry.lines[1].account_number, "3001"); assert_eq!(entry.lines[1].debit, None); assert_eq!(entry.lines[1].credit, Some(Money(10000000))); // Line 3: credit 25000 assert_eq!(entry.lines[2].account_number, "2611"); assert_eq!(entry.lines[2].debit, None); assert_eq!(entry.lines[2].credit, Some(Money(2500000))); } #[test] fn test_parse_multiple_ver() { let sie4_data = r#"#VER A 1 20260115 "Faktura 1" #TRANS 1510 {10000.00} #TRANS 3001 {-10000.00} #VER A 2 20260116 "Faktura 2" #TRANS 1510 {20000.00} #TRANS 3001 {-20000.00} "#; let entries = parse_entries(sie4_data.as_bytes()).unwrap(); assert_eq!(entries.len(), 2); assert_eq!(entries[0].description, "Faktura 1"); assert_eq!(entries[1].description, "Faktura 2"); } #[test] fn test_parse_with_cost_center_and_project() { let sie4_data = r#"#VER A 1 20260115 "Kostnad" #TRANS 6991 {5000.00} 20260115 "Resa" {} {} "1" "CC01" "6" "PROJ01" "#; let entries = parse_entries(sie4_data.as_bytes()).unwrap(); assert_eq!(entries.len(), 1); let line = &entries[0].lines[0]; assert_eq!(line.cost_center, Some("CC01".to_string())); assert_eq!(line.project_code, Some("PROJ01".to_string())); } #[test] fn test_empty_sie4() { let entries = parse_entries("#FLAGGA 0\n".as_bytes()).unwrap(); assert!(entries.is_empty()); } #[test] fn test_unbalanced_entry_still_parsed() { // Parser does not enforce balance; that is a validation concern let sie4_data = r#"#VER A 1 20260115 "Obalanserad" #TRANS 1510 {10000.00} #TRANS 3001 {-5000.00} "#; let entries = parse_entries(sie4_data.as_bytes()).unwrap(); assert_eq!(entries.len(), 1); assert!(!entries[0].is_balanced()); } }