Files
boc/aamos-ledger-rust/crates/aamos-sie4/src/exporter.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

345 lines
13 KiB
Rust

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<W: Write>(
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<W: Write>(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<String> = 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<JournalLine>,
) -> 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<i64>, credit: Option<i64>) -> 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");
}
}