use aamos_core::{LedgerError, LedgerResult}; use aamos_ledger::{EntryStatus, JournalEntry, JournalLine, SourceType}; use std::io::Write; /// Write a SIE4 file from journal entries. /// /// SIE4 format specification (relevant tags): /// - #FLAGGA 0 — normal file /// - #FORMAT PC8 — encoding /// - #SIETYP 4 — SIE4 type /// - #FNR — company number (placeholder) /// - #FNAMN — company name (placeholder) /// - #ORGNR — organization number (placeholder) /// - #KPTYP — chart of accounts type (BAS2024) /// - #RAR — fiscal year boundaries /// - #KONTO — account definitions /// - #VER — vouchers (verifikationer) /// - #TRANS — transaction lines within vouchers /// - #SRU — SRU codes (optional, not included) /// /// Amounts in SIE4 are always in base currency (SEK) with two decimals. /// Positive = debit, negative = credit. pub fn write_sie4( writer: &mut W, entries: &[JournalEntry], accounts: &[(String, String)], // (account_number, name) fiscal_year: i32, ) -> LedgerResult<()> { // ── Header ──────────────────────────────────────────────────────────── writeln!(writer, "#FLAGGA 0").map_err(io_err)?; writeln!(writer, "#FORMAT PC8").map_err(io_err)?; writeln!(writer, "#SIETYP 4").map_err(io_err)?; writeln!(writer, "#FNR 1").map_err(io_err)?; writeln!(writer, "#FNAMN \"AAMOS Export\"").map_err(io_err)?; writeln!(writer, "#ORGNR 0000000000").map_err(io_err)?; writeln!(writer, "#KPTYP BAS2024").map_err(io_err)?; // ── Fiscal year ─────────────────────────────────────────────────────── // RAR 0 = current year, format: #RAR 0 YYYYMMDD YYYYMMDD let year_start = format!("{}0101", fiscal_year); let year_end = format!("{}1231", fiscal_year); writeln!(writer, "#RAR 0 {} {}", year_start, year_end).map_err(io_err)?; // ── Accounts ────────────────────────────────────────────────────────── for (number, name) in accounts { writeln!(writer, "#KONTO {} \"{}\"", number, escape_sie_string(name)).map_err(io_err)?; } // ── Vouchers ────────────────────────────────────────────────────────── for entry in entries { // Skip voided entries if entry.status == EntryStatus::Voided { continue; } let series = source_type_to_series(entry.source_type); let entry_num = entry.entry_number.map(|n| n.to_string()).unwrap_or_else(|| "0".to_string()); let date = entry.entry_date.format("%Y%m%d").to_string(); let desc = escape_sie_string(&entry.description); let reg_date = entry.posted_at.map(|d| d.format("%Y%m%d").to_string()) .unwrap_or_else(|| date.clone()); let reg_by = escape_sie_string(&entry.user_id); writeln!( writer, "#VER {} {} {} \"{}\" {} \"{}\"", series, entry_num, date, desc, reg_date, reg_by ).map_err(io_err)?; writeln!(writer, "{{").map_err(io_err)?; for line in &entry.lines { write_trans_line(writer, line)?; } writeln!(writer, "}}").map_err(io_err)?; } Ok(()) } // ── Transaction line writer ─────────────────────────────────────────────── fn write_trans_line(writer: &mut W, line: &JournalLine) -> LedgerResult<()> { let amount = match (line.debit, line.credit) { (Some(d), None) => d.to_decimal(), (None, Some(c)) => -c.to_decimal(), _ => 0.0, }; let account = &line.account_number; let _date: Option = None; // SIE4 trans date optional let desc = line.description.as_deref().unwrap_or(""); // Format: #TRANS {account} {amount} {date} {description} {quantity} // We always include date (entry_date) and description if present. // Per-entry date is enough; trans date optional if !desc.is_empty() { writeln!( writer, "#TRANS {} {{{:.2}}} \"{}\"", account, amount, escape_sie_string(desc) ).map_err(io_err)?; } else { writeln!(writer, "#TRANS {} {{{:.2}}}", account, amount).map_err(io_err)?; } Ok(()) } // ── Helpers ─────────────────────────────────────────────────────────────── fn source_type_to_series(source_type: SourceType) -> char { match source_type { SourceType::Bank => 'B', SourceType::ImportSie => 'S', _ => 'A', } } /// Escape a string for SIE4: replace " with ' and wrap in quotes if needed. fn escape_sie_string(s: &str) -> String { s.replace('"', "'") } fn io_err(e: std::io::Error) -> LedgerError { LedgerError::Internal(format!("SIE4 export I/O error: {}", e)) } // ── Tests ───────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; use aamos_core::{Currency, DecisionSource, Money, Period, TenantId}; use aamos_ledger::{EntryStatus, JournalEntry, JournalLine, SourceType}; use chrono::{NaiveDate, Utc}; use uuid::Uuid; fn test_tenant() -> TenantId { TenantId::new("test-tenant") } fn make_entry( entry_number: i64, description: &str, lines: Vec, ) -> JournalEntry { let now = Utc::now(); JournalEntry { id: Uuid::new_v4(), tenant_id: test_tenant(), entry_number: Some(entry_number), fiscal_year: 2026, period: Period::new(2026, 1).unwrap(), entry_date: NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(), description: description.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: now, posted_at: None, lines, } } fn make_line(account: &str, debit: Option, credit: Option) -> JournalLine { JournalLine { id: Uuid::new_v4(), entry_id: Uuid::new_v4(), tenant_id: test_tenant(), line_number: 1, account_number: account.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_export_single_entry() { let entry = make_entry( 1, "Faktura #1234", vec![ make_line("1510", Some(125000), None), make_line("3001", None, Some(100000)), make_line("2611", None, Some(25000)), ], ); let mut buf = Vec::new(); let accounts = vec![ ("1510".to_string(), "Kundfordringar".to_string()), ("3001".to_string(), "Konsultintäkter".to_string()), ("2611".to_string(), "Utgående moms 25%".to_string()), ]; write_sie4(&mut buf, &[entry], &accounts, 2026).unwrap(); let output = String::from_utf8(buf).unwrap(); assert!(output.contains("#FLAGGA 0")); assert!(output.contains("#SIETYP 4")); assert!(output.contains("#RAR 0 20260101 20261231")); assert!(output.contains("#KONTO 1510 \"Kundfordringar\"")); assert!(output.contains("#VER A 1 20260115 \"Faktura #1234\"")); assert!(output.contains("#TRANS 1510 {1250.00}")); assert!(output.contains("#TRANS 3001 {-1000.00}")); assert!(output.contains("#TRANS 2611 {-250.00}")); } #[test] fn test_export_skips_voided() { let mut entry = make_entry( 1, "Voided entry", vec![make_line("1910", Some(10000), None)], ); entry.status = EntryStatus::Voided; let mut buf = Vec::new(); write_sie4(&mut buf, &[entry], &[], 2026).unwrap(); let output = String::from_utf8(buf).unwrap(); assert!(!output.contains("Voided entry")); } #[test] fn test_export_bank_series() { let mut entry = make_entry(1, "Bank", vec![make_line("1910", Some(10000), None)]); entry.source_type = SourceType::Bank; let mut buf = Vec::new(); write_sie4(&mut buf, &[entry], &[], 2026).unwrap(); let output = String::from_utf8(buf).unwrap(); assert!(output.contains("#VER B 1")); } #[test] fn test_export_sie_series() { let mut entry = make_entry(1, "SIE import", vec![make_line("1910", Some(10000), None)]); entry.source_type = SourceType::ImportSie; let mut buf = Vec::new(); write_sie4(&mut buf, &[entry], &[], 2026).unwrap(); let output = String::from_utf8(buf).unwrap(); assert!(output.contains("#VER S 1")); } #[test] fn test_escape_quotes() { let entry = make_entry(1, "Faktura \"Special\"", vec![]); let mut buf = Vec::new(); write_sie4(&mut buf, &[entry], &[], 2026).unwrap(); let output = String::from_utf8(buf).unwrap(); assert!(output.contains("Faktura 'Special'")); } #[test] fn test_export_empty_entries() { let mut buf = Vec::new(); write_sie4(&mut buf, &[], &[], 2026).unwrap(); let output = String::from_utf8(buf).unwrap(); assert!(output.contains("#FLAGGA 0")); assert!(output.contains("#SIETYP 4")); assert!(!output.contains("#VER")); } #[test] fn test_export_with_line_description() { let mut line = make_line("1510", Some(10000), None); line.description = Some("Kundfordran faktura 123".to_string()); let entry = make_entry(1, "Test", vec![line]); let mut buf = Vec::new(); write_sie4(&mut buf, &[entry], &[], 2026).unwrap(); let output = String::from_utf8(buf).unwrap(); assert!(output.contains("#TRANS 1510 {100.00} \"Kundfordran faktura 123\"")); } #[test] fn test_export_roundtrip() { use crate::parser::parse_entries; let entry = make_entry( 42, "Roundtrip test", vec![ make_line("1910", Some(50000), None), make_line("3000", None, Some(40000)), make_line("2611", None, Some(10000)), ], ); let mut buf = Vec::new(); let accounts = vec![ ("1910".to_string(), "Kassa".to_string()), ("3000".to_string(), "Försäljning".to_string()), ("2611".to_string(), "Moms".to_string()), ]; write_sie4(&mut buf, &[entry.clone()], &accounts, 2026).unwrap(); let exported = String::from_utf8(buf.clone()).unwrap(); println!("Exported SIE4:\n{}", exported); let parsed = parse_entries(&buf[..]).unwrap(); assert_eq!(parsed.len(), 1); let parsed_entry = &parsed[0]; assert_eq!(parsed_entry.description, "Roundtrip test"); assert_eq!(parsed_entry.lines.len(), 3); assert!(parsed_entry.is_balanced()); // Verify amounts (roundtrip: 50000 ören -> 500.00 -> 50000 ören) assert_eq!(parsed_entry.lines[0].debit, Some(Money(50000))); assert_eq!(parsed_entry.lines[0].account_number, "1910"); assert_eq!(parsed_entry.lines[1].credit, Some(Money(40000))); assert_eq!(parsed_entry.lines[1].account_number, "3000"); assert_eq!(parsed_entry.lines[2].credit, Some(Money(10000))); assert_eq!(parsed_entry.lines[2].account_number, "2611"); } }