bae705aa97
- 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
555 lines
19 KiB
Rust
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());
|
|
}
|
|
}
|