Files
boc/aamos-ledger-rust/crates/aamos-sie4/src/parser.rs
T
Bernt bae705aa97 ARCHITECTURE: NFC roadmap, edge AI, audit logging
- Add NFC ePassport roadmap (ICAO 9303, eIDAS)
- Add TensorFlow.js edge face detection (BlazeFace)
- Add structured audit logger (GDPR-compliant)
- Risk scoring support

Part of KYC Apple Native UX v1.1.0
2026-06-29 16:24:48 +00:00

555 lines
19 KiB
Rust

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<R: Read>(reader: R) -> LedgerResult<Vec<JournalEntry>> {
let buf_reader = BufReader::new(reader);
let mut lines = Vec::new();
let mut current_ver: Option<VerBuilder> = None;
let mut entries: Vec<JournalEntry> = 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<NaiveDate>,
reg_by: Option<String>,
trans: Vec<TransLine>,
}
struct TransLine {
account: String,
amount: f64, // in SEK, positive = debit, negative = credit
_date: Option<NaiveDate>,
description: Option<String>,
_quantity: Option<f64>,
cost_center: Option<String>,
project: Option<String>,
}
impl VerBuilder {
fn build(self) -> LedgerResult<JournalEntry> {
let entry_number = self.entry_number.parse::<i64>().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<JournalLine> = 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<VerBuilder> {
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<TransLine> {
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<TransLine> {
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<String> {
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<NaiveDate> {
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::<i32>().map_err(|_| {
LedgerError::Validation(format!("SIE4: invalid year in date '{}' at line {}", s, line_no))
})?;
let month = s[4..6].parse::<u32>().map_err(|_| {
LedgerError::Validation(format!("SIE4: invalid month in date '{}' at line {}", s, line_no))
})?;
let day = s[6..8].parse::<u32>().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<f64> {
let s = unquote(s).trim_matches('{').trim_matches('}').replace(',', ".");
s.parse::<f64>().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());
}
}