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
345 lines
13 KiB
Rust
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");
|
|
}
|
|
}
|