Files
boc/aamos-ledger-rust/DESIGN.md
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

75 KiB

AAMOS Ledger Rust — Designspecifikation

Översikt

Ett nytt Rust-baserat ekonomisystem som ersätter den nuvarande Node.js-versionen (aamos-ledger). Systemet behåller alla nuvarande funktioner men med Rusts minnessäkerhet, prestanda och robusthet.

Arkitekturprinciper (från BYGGPLAN 0.001)

  • Determinism före AI — alla beslut reproducerbara
  • Audit First — varje operation genererar event + auditpost
  • Tenant Isolation — kunddata korsas aldrig
  • Hermes — alla ekonomiska händelser publiceras via event fabric
  • Type Safety — kompileringstidsgarantier via Rusts typsystem
  • Async First — Tokio-baserad async runtime

Teknisk Stack

Komponent Nuvarande (Node.js) Ny (Rust)
Runtime Node.js 20 Tokio
Web framework Express Axum
Serialization JSON (native) Serde + JSON
Database pg (node-postgres) sqlx
Auth jsonwebtoken (npm) jsonwebtoken (crate)
Validation Zod Validator + garde
HTTP Client fetch reqwest
Config dotenv config + dotenvy
Logging console tracing + tracing-subscriber
Metrics none metrics + metrics-exporter-prometheus
Testing jest cargo test + tokio-test

Modulstruktur

aamos-ledger-rust/
├── Cargo.toml                 # Workspace-root
├── crates/
│   ├── aamos-core/            # Delade typer, felhantering, utilities
│   ├── aamos-db/              # Databas-lager (sqlx, migrationer)
│   ├── aamos-auth/            # JWT, RBAC, sessioner
│   ├── aamos-audit/           # Audit-logging
│   ├── aamos-ledger/          # Kärnmodul: verifikat, konton, perioder
│   ├── aamos-sie4/            # SIE4 import/export
│   ├── aamos-reports/         # Rapporter (balans, resultat, transaktioner)
│   ├── aamos-bank-import/     # Bankimport (Nordea, Revolut)
│   ├── aamos-tax/             # Skatteverket-integration (AGD, moms)
│   ├── aamos-payroll/         # Lönesystem-integration
│   ├── aamos-api/             # REST API (Axum-routes)
│   └── aamos-hermes/          # Event fabric integration
├── migrations/                # SQLx migrationer
├── scripts/
│   ├── seed-bas-2024.sql      # BAS-konton seed
│   └── healthcheck.sh
├── tests/
│   ├── integration/           # API-integrationstester
│   └── e2e/                   # End-to-end-tester
├── docker/
│   ├── Dockerfile
│   └── docker-compose.yml
└── docs/
    ├── api.md                 # API-dokumentation
    ├── deployment.md
    └── security.md

1. Kärnmoduler

1.1 aamos-core — Delade typer och utilities

Syfte: Grundläggande typer, felhantering, validering som används av alla crates.

// crates/aamos-core/src/lib.rs

use serde::{Deserialize, Serialize};
use std::fmt;
use uuid::Uuid;

// ── Tenant Isolation ──────────────────────────────────────────────────────
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct TenantId(pub String);

impl TenantId {
    pub fn new(id: impl Into<String>) -> Self { Self(id.into()) }
}

// ── Trace Context ─────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TraceContext {
    pub trace_id: Uuid,
    pub correlation_id: Uuid,
    pub tenant_id: TenantId,
    pub user_id: String,
    pub entity_type: Option<String>,
    pub entity_id: Option<String>,
    pub decision_source: DecisionSource,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DecisionSource {
    User,
    System,
    Agent,
}

impl Default for DecisionSource {
    fn default() -> Self { DecisionSource::User }
}

// ── Valuta ────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Currency {
    SEK,
    EUR,
    USD,
    NOK,
    DKK,
}

impl Default for Currency {
    fn default() -> Self { Currency::SEK }
}

// ── Belopp ────────────────────────────────────────────────────────────────
/// Belopp i ören/cent (heltal) för att undvika flyttalsfel.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct Money(pub i64);

impl Money {
    pub fn from_decimal(amount: f64) -> Self {
        Self((amount * 100.0).round() as i64)
    }
    pub fn to_decimal(&self) -> f64 {
        self.0 as f64 / 100.0
    }
    pub fn zero() -> Self { Self(0) }
    pub fn is_zero(&self) -> bool { self.0 == 0 }
}

impl std::ops::Add for Money {
    type Output = Self;
    fn add(self, rhs: Self) -> Self::Output { Self(self.0 + rhs.0) }
}

impl std::ops::Sub for Money {
    type Output = Self;
    fn sub(self, rhs: Self) -> Self::Output { Self(self.0 - rhs.0) }
}

impl std::ops::Neg for Money {
    type Output = Self;
    fn neg(self) -> Self::Output { Self(-self.0) }
}

// ── Felhantering ──────────────────────────────────────────────────────────
#[derive(Debug, thiserror::Error)]
pub enum LedgerError {
    #[error("Database error: {0}")]
    Database(#[from] sqlx::Error),
    #[error("Validation error: {0}")]
    Validation(String),
    #[error("Not found: {entity_type} {entity_id}")]
    NotFound { entity_type: String, entity_id: String },
    #[error("Unauthorized: {0}")]
    Unauthorized(String),
    #[error("Forbidden: {0}")]
    Forbidden(String),
    #[error("Conflict: {0}")]
    Conflict(String),
    #[error("Bad request: {0}")]
    BadRequest(String),
    #[error("Internal error: {0}")]
    Internal(String),
}

pub type LedgerResult<T> = Result<T, LedgerError>;

// ── Period ────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Period {
    pub year: i32,
    pub month: u8, // 1-12
}

impl Period {
    pub fn new(year: i32, month: u8) -> LedgerResult<Self> {
        if month < 1 || month > 12 {
            return Err(LedgerError::Validation(format!("Invalid month: {}", month)));
        }
        Ok(Self { year, month })
    }
    pub fn to_string(&self) -> String {
        format!("{:04}-{:02}", self.year, self.month)
    }
    pub fn from_str(s: &str) -> LedgerResult<Self> {
        let parts: Vec<&str> = s.split('-').collect();
        if parts.len() != 2 {
            return Err(LedgerError::Validation(format!("Invalid period format: {}", s)));
        }
        let year = parts[0].parse().map_err(|_| LedgerError::Validation(format!("Invalid year: {}", parts[0])))?;
        let month = parts[1].parse().map_err(|_| LedgerError::Validation(format!("Invalid month: {}", parts[1])))?;
        Self::new(year, month)
    }
}

1.2 aamos-db — Databas-lager

Syfte: All databasåtkomst, connection pooling, migrationer.

// crates/aamos-db/src/lib.rs

use sqlx::{postgres::PgPoolOptions, PgPool};
use std::time::Duration;

pub async fn create_pool(database_url: &str) -> Result<PgPool, sqlx::Error> {
    PgPoolOptions::new()
        .max_connections(10)
        .acquire_timeout(Duration::from_secs(30))
        .idle_timeout(Duration::from_secs(300))
        .connect(database_url)
        .await
}

pub async fn run_migrations(pool: &PgPool) -> Result<(), sqlx::migrate::MigrateError> {
    sqlx::migrate!("./migrations").run(pool).await
}

1.3 aamos-auth — Autentisering och auktorisering

Syfte: JWT-validering, rollbaserad åtkomstkontroll (RBAC).

// crates/aamos-auth/src/lib.rs

use aamos_core::{LedgerError, LedgerResult, TenantId};
use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;

// ── Roller ────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Role {
    Admin,
    Accountant,
    Viewer,
    Auditor,
    Payroll,
}

impl Role {
    pub fn as_str(&self) -> &'static str {
        match self {
            Role::Admin => "admin",
            Role::Accountant => "accountant",
            Role::Viewer => "viewer",
            Role::Auditor => "auditor",
            Role::Payroll => "payroll",
        }
    }
}

impl std::str::FromStr for Role {
    type Err = LedgerError;
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "admin" => Ok(Role::Admin),
            "accountant" => Ok(Role::Accountant),
            "viewer" => Ok(Role::Viewer),
            "auditor" => Ok(Role::Auditor),
            "payroll" => Ok(Role::Payroll),
            _ => Err(LedgerError::Validation(format!("Unknown role: {}", s))),
        }
    }
}

// ── JWT Claims ────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Claims {
    pub sub: String,           // user_id
    pub tenant_id: String,
    pub roles: Vec<String>,
    pub exp: usize,
    pub iat: usize,
}

// ── AuthUser ──────────────────────────────────────────────────────────────
#[derive(Debug, Clone)]
pub struct AuthUser {
    pub user_id: String,
    pub tenant_id: TenantId,
    pub roles: HashSet<Role>,
}

impl AuthUser {
    pub fn has_role(&self, role: Role) -> bool {
        self.roles.contains(&role)
    }
    pub fn has_any_role(&self, roles: &[Role]) -> bool {
        roles.iter().any(|r| self.roles.contains(r))
    }
    pub fn require_role(&self, roles: &[Role]) -> LedgerResult<()> {
        if self.has_any_role(roles) {
            Ok(())
        } else {
            Err(LedgerError::Forbidden(format!(
                "Requires one of: {}",
                roles.iter().map(|r| r.as_str()).collect::<Vec<_>>().join(", ")
            )))
        }
    }
}

// ── JWT-validering ────────────────────────────────────────────────────────
pub struct JwtValidator {
    decoding_key: DecodingKey,
    validation: Validation,
}

impl JwtValidator {
    pub fn from_secret(secret: &str) -> Self {
        let mut validation = Validation::new(Algorithm::HS256);
        validation.set_required_spec_claims(&["exp", "sub", "tenant_id"]);
        Self {
            decoding_key: DecodingKey::from_secret(secret.as_bytes()),
            validation,
        }
    }

    pub fn from_rsa_pem(pem: &[u8]) -> Result<Self, jsonwebtoken::errors::Error> {
        let mut validation = Validation::new(Algorithm::RS256);
        validation.set_required_spec_claims(&["exp", "sub", "tenant_id"]);
        Ok(Self {
            decoding_key: DecodingKey::from_rsa_pem(pem)?,
            validation,
        })
    }

    pub fn validate(&self, token: &str) -> LedgerResult<AuthUser> {
        let token_data = decode::<Claims>(token, &self.decoding_key, &self.validation)
            .map_err(|e| LedgerError::Unauthorized(e.to_string()))?;

        let claims = token_data.claims;
        let roles: HashSet<Role> = claims
            .roles
            .iter()
            .filter_map(|r| r.parse().ok())
            .collect();

        Ok(AuthUser {
            user_id: claims.sub,
            tenant_id: TenantId::new(claims.tenant_id),
            roles,
        })
    }
}

1.4 aamos-ledger — Kärnmodul

Syfte: Verifikathantering, kontoplan, perioder.

// crates/aamos-ledger/src/models.rs

use aamos_core::{Currency, DecisionSource, Money, Period, TenantId, TraceContext};
use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

// ── Konto ─────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Account {
    pub id: Uuid,
    pub tenant_id: TenantId,
    pub account_number: String,    // t.ex. "1910", "3000"
    pub name: String,
    pub account_type: AccountType,
    pub normal_balance: BalanceSide,
    pub coa_standard: CoaStandard,
    pub parent_account: Option<String>,
    pub vat_code: Option<String>,
    pub is_active: bool,
    pub metadata: serde_json::Value,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AccountType {
    Asset,
    Liability,
    Equity,
    Revenue,
    Expense,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum BalanceSide {
    Debit,
    Credit,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CoaStandard {
    BAS,
    IFRS,
    USGAAP,
    Custom,
}

// ── Verifikation ──────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JournalEntry {
    pub id: Uuid,
    pub tenant_id: TenantId,
    pub entry_number: Option<i64>, // löpnummer per tenant+räkenskapsår
    pub fiscal_year: i32,
    pub period: Period,
    pub entry_date: NaiveDate,
    pub description: String,
    pub reference: Option<String>,
    pub source_type: SourceType,
    pub source_id: Option<String>,
    pub status: EntryStatus,
    pub void_reason: Option<String>,
    pub voided_at: Option<DateTime<Utc>>,
    pub voided_by: Option<String>,
    pub trace_id: Uuid,
    pub correlation_id: Uuid,
    pub user_id: String,
    pub decision_source: DecisionSource,
    pub metadata: serde_json::Value,
    pub created_at: DateTime<Utc>,
    pub posted_at: Option<DateTime<Utc>>,
    pub lines: Vec<JournalLine>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SourceType {
    Manual,
    ImportSie,
    ImportCsv,
    Bank,
    System,
    Agent,
    OpeningBalance,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum EntryStatus {
    Draft,
    Posted,
    Voided,
}

// ── Verifikationsrad ──────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JournalLine {
    pub id: Uuid,
    pub entry_id: Uuid,
    pub tenant_id: TenantId,
    pub line_number: i32,
    pub account_number: String,
    pub account_name: Option<String>,
    pub debit: Option<Money>,
    pub credit: Option<Money>,
    pub currency: Currency,
    pub amount_base: Option<Money>,
    pub vat_code: Option<String>,
    pub vat_amount: Option<Money>,
    pub cost_center: Option<String>,
    pub project_code: Option<String>,
    pub description: Option<String>,
    pub metadata: serde_json::Value,
}

// ── Period ────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LedgerPeriod {
    pub id: Uuid,
    pub tenant_id: TenantId,
    pub fiscal_year: i32,
    pub period: Period,
    pub status: PeriodStatus,
    pub opened_at: DateTime<Utc>,
    pub closed_at: Option<DateTime<Utc>>,
    pub closed_by: Option<String>,
    pub workflow_id: Option<Uuid>,
    pub trial_balance: Option<serde_json::Value>,
    pub trace_id: Uuid,
    pub metadata: serde_json::Value,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PeriodStatus {
    Open,
    Review,
    Approved,
    Closed,
}

// ── Validering ────────────────────────────────────────────────────────────
impl JournalEntry {
    /// Validerar att verifikationen är balanserad (summa debet = summa kredit).
    pub fn is_balanced(&self) -> bool {
        let total_debit: Money = self.lines.iter().filter_map(|l| l.debit).fold(Money::zero(), |a, b| a + b);
        let total_credit: Money = self.lines.iter().filter_map(|l| l.credit).fold(Money::zero(), |a, b| a + b);
        total_debit == total_credit
    }

    /// Validerar att alla rader har antingen debet ELLER kredit (inte båda, inte inget).
    pub fn validate_lines(&self) -> Result<(), String> {
        for (i, line) in self.lines.iter().enumerate() {
            match (&line.debit, &line.credit) {
                (Some(_), Some(_)) => return Err(format!("Line {} has both debit and credit", i + 1)),
                (None, None) => return Err(format!("Line {} has neither debit nor credit", i + 1)),
                _ => {}
            }
        }
        Ok(())
    }
}

1.5 aamos-sie4 — SIE4 Import/Export

Syfte: Hantering av SIE4-formatet (svensk bokföringsstandard).

// crates/aamos-sie4/src/lib.rs

use aamos_core::{LedgerError, LedgerResult, Money, Period, TenantId};
use aamos_ledger::{JournalEntry, JournalLine, SourceType};
use chrono::NaiveDate;
use std::io::{Read, Write};

pub mod parser;
pub mod exporter;

// ── SIE4 Parser ───────────────────────────────────────────────────────────
pub struct Sie4Parser;

impl Sie4Parser {
    pub fn parse<R: Read>(reader: R) -> LedgerResult<Vec<JournalEntry>> {
        parser::parse_entries(reader)
    }
}

// ── SIE4 Exporter ─────────────────────────────────────────────────────────
pub struct Sie4Exporter;

impl Sie4Exporter {
    pub fn export<W: Write>(
        writer: &mut W,
        entries: &[JournalEntry],
        accounts: &[(String, String)], // (account_number, name)
        fiscal_year: i32,
    ) -> LedgerResult<()> {
        exporter::write_sie4(writer, entries, accounts, fiscal_year)
    }
}

// ── SIE4-serie ────────────────────────────────────────────────────────────
pub fn source_type_to_series(source_type: SourceType) -> char {
    match source_type {
        SourceType::Bank => 'B',
        SourceType::ImportSie => 'S',
        _ => 'A',
    }
}

1.6 aamos-reports — Rapporter

Syfte: Generering av ekonomiska rapporter.

// crates/aamos-reports/src/lib.rs

use aamos_core::{LedgerResult, Money, Period, TenantId};
use aamos_ledger::AccountType;
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};

pub mod balance_sheet;
pub mod income_statement;
pub mod transaction_report;
pub mod trial_balance;

// ── Balansrapport ─────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BalanceSheet {
    pub tenant_id: TenantId,
    pub period: Period,
    pub assets: Vec<AccountBalance>,
    pub liabilities: Vec<AccountBalance>,
    pub equity: Vec<AccountBalance>,
    pub total_assets: Money,
    pub total_liabilities: Money,
    pub total_equity: Money,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountBalance {
    pub account_number: String,
    pub account_name: String,
    pub balance: Money,
}

// ── Resultatrapport ───────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IncomeStatement {
    pub tenant_id: TenantId,
    pub period: Period,
    pub revenues: Vec<AccountBalance>,
    pub expenses: Vec<AccountBalance>,
    pub total_revenue: Money,
    pub total_expense: Money,
    pub net_income: Money,
}

// ── Transaktionsrapport ───────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransactionReport {
    pub tenant_id: TenantId,
    pub from_date: NaiveDate,
    pub to_date: NaiveDate,
    pub entries: Vec<TransactionEntry>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransactionEntry {
    pub entry_number: Option<i64>,
    pub entry_date: NaiveDate,
    pub description: String,
    pub reference: Option<String>,
    pub lines: Vec<TransactionLine>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransactionLine {
    pub account_number: String,
    pub account_name: String,
    pub debit: Option<Money>,
    pub credit: Option<Money>,
}

1.7 aamos-bank-import — Bankimport

Syfte: Importera transaktioner från banker.

// crates/aamos-bank-import/src/lib.rs

use aamos_core::{LedgerError, LedgerResult, Money, TenantId};
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};

pub mod nordea;
pub mod revolut;

// ── Banktransaktion ───────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BankTransaction {
    pub transaction_id: String,
    pub account_number: String,
    pub transaction_date: NaiveDate,
    pub description: String,
    pub amount: Money,
    pub currency: String,
    pub reference: Option<String>,
    pub counterparty: Option<String>,
    pub raw_data: serde_json::Value,
}

// ── Bankimport-trait ──────────────────────────────────────────────────────
#[async_trait::async_trait]
pub trait BankImporter: Send + Sync {
    async fn fetch_transactions(
        &self,
        tenant_id: &TenantId,
        account_number: &str,
        from_date: NaiveDate,
        to_date: NaiveDate,
    ) -> LedgerResult<Vec<BankTransaction>>;
}

// ── Importresultat ────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImportResult {
    pub imported_count: usize,
    pub skipped_count: usize,
    pub failed_count: usize,
    pub transactions: Vec<BankTransaction>,
}

1.8 aamos-tax — Skatteverket-integration

Syfte: AGD (arbetsgivardeklaration) och momsredovisning.

// crates/aamos-tax/src/lib.rs

use aamos_core::{LedgerResult, Money, Period, TenantId};
use serde::{Deserialize, Serialize};

pub mod agd;
pub mod moms;

// ── Momsrapport ───────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MomsReport {
    pub tenant_id: TenantId,
    pub period: Period,
    pub moms_utgaende: Money,    // 25%, 12%, 6%
    pub moms_ingaende: Money,
    pub moms_att_betala: Money,
    pub detaljer: Vec<MomsDetalj>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MomsDetalj {
    pub moms_kod: String,        // t.ex. "MP1", "MP2", "MP3"
    pub beskrivning: String,
    pub belopp: Money,
}

// ── AGD-rapport ───────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgdReport {
    pub tenant_id: TenantId,
    pub period: Period,
    pub arbetsgivaravgift: Money,
    pub avdragen_skatt: Money,
    pub sociala_avgifter: Money,
    pub detaljer: Vec<AgdDetalj>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgdDetalj {
    pub avgiftskod: String,
    pub beskrivning: String,
    pub belopp: Money,
}

1.9 aamos-payroll — Lönesystem-integration

Syfte: Integration med lönesystem.

// crates/aamos-payroll/src/lib.rs

use aamos_core::{LedgerResult, Money, Period, TenantId};
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use uuid::Uuid;

// ── Lönekörning ───────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PayrollRun {
    pub id: Uuid,
    pub tenant_id: TenantId,
    pub period: Period,
    pub run_date: NaiveDate,
    pub status: PayrollStatus,
    pub employees: Vec<PayrollEmployee>,
    pub total_gross: Money,
    pub total_tax: Money,
    pub total_net: Money,
    pub total_employer_fees: Money,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PayrollStatus {
    Draft,
    Approved,
    Processed,
    Paid,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PayrollEmployee {
    pub employee_id: String,
    pub name: String,
    pub gross_salary: Money,
    pub tax_deduction: Money,
    pub net_salary: Money,
    pub employer_fees: Money,
}

// ── Payroll-journalrader ──────────────────────────────────────────────────
/// Genererar journalrader för en lönekörning.
pub fn generate_journal_lines(run: &PayrollRun) -> Vec<PayrollJournalLine> {
    let mut lines = vec![];
    // Lönekostnad (debet)
    lines.push(PayrollJournalLine {
        account_number: "7010".to_string(),
        description: Some(format!("Lönekostnad {}", run.period.to_string())),
        debit: Some(run.total_gross),
        credit: None,
    });
    // Arbetsgivaravgift (debet)
    lines.push(PayrollJournalLine {
        account_number: "7510".to_string(),
        description: Some(format!("Arbetsgivaravgift {}", run.period.to_string())),
        debit: Some(run.total_employer_fees),
        credit: None,
    });
    // Preliminärskatt (kredit)
    lines.push(PayrollJournalLine {
        account_number: "2012".to_string(),
        description: Some(format!("Preliminärskatt {}", run.period.to_string())),
        debit: None,
        credit: Some(run.total_tax),
    });
    // Sociala avgifter (kredit)
    lines.push(PayrollJournalLine {
        account_number: "2013".to_string(),
        description: Some(format!("Sociala avgifter {}", run.period.to_string())),
        debit: None,
        credit: Some(run.total_employer_fees),
    });
    // Löneskulder (kredit)
    lines.push(PayrollJournalLine {
        account_number: "2010".to_string(),
        description: Some(format!("Löneskulder {}", run.period.to_string())),
        debit: None,
        credit: Some(run.total_net),
    });
    lines
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PayrollJournalLine {
    pub account_number: String,
    pub description: Option<String>,
    pub debit: Option<Money>,
    pub credit: Option<Money>,
}

1.10 aamos-audit — Audit-logging

Syfte: Spårbarhet för alla förändringar.

// crates/aamos-audit/src/lib.rs

use aamos_core::{TraceContext, TenantId};
use chrono::{DateTime, Utc};
use serde_json::Value;
use sqlx::PgPool;
use uuid::Uuid;

// ── Audit-logg ────────────────────────────────────────────────────────────
#[derive(Debug, Clone)]
pub struct AuditLog {
    pub id: Uuid,
    pub tenant_id: TenantId,
    pub trace_id: Uuid,
    pub correlation_id: Uuid,
    pub user_id: String,
    pub entity_type: String,
    pub entity_id: String,
    pub action: String,
    pub decision_source: String,
    pub before_state: Option<Value>,
    pub after_state: Option<Value>,
    pub ip_address: Option<String>,
    pub session_id: Option<String>,
    pub ts: DateTime<Utc>,
}

// ── Audit-writer ──────────────────────────────────────────────────────────
pub struct AuditWriter {
    pool: PgPool,
}

impl AuditWriter {
    pub fn new(pool: PgPool) -> Self { Self { pool } }

    pub async fn write(
        &self,
        ctx: &TraceContext,
        action: &str,
        before: Option<Value>,
        after: Option<Value>,
    ) -> Result<(), sqlx::Error> {
        sqlx::query(
            r#"
            INSERT INTO ledger_audit_log
              (tenant_id, trace_id, correlation_id, user_id,
               entity_type, entity_id, action, decision_source,
               before_state, after_state)
            VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
            "#
        )
        .bind(ctx.tenant_id.0.clone())
        .bind(ctx.trace_id)
        .bind(ctx.correlation_id)
        .bind(&ctx.user_id)
        .bind(ctx.entity_type.as_deref().unwrap_or("unknown"))
        .bind(ctx.entity_id.as_deref().unwrap_or("unknown"))
        .bind(action)
        .bind(format!("{:?}", ctx.decision_source).to_lowercase())
        .bind(before)
        .bind(after)
        .execute(&self.pool)
        .await?;
        Ok(())
    }
}

1.11 aamos-hermes — Event Fabric

Syfte: Publicera ekonomiska händelser.

// crates/aamos-hermes/src/lib.rs

use aamos_core::TraceContext;
use serde::{Deserialize, Serialize};

// ── Event ─────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HermesEvent {
    pub event_type: String,
    pub trace_id: String,
    pub correlation_id: String,
    pub tenant_id: String,
    pub user_id: String,
    pub payload: serde_json::Value,
    pub timestamp: chrono::DateTime<chrono::Utc>,
}

// ── Hermes-klient ─────────────────────────────────────────────────────────
#[async_trait::async_trait]
pub trait HermesClient: Send + Sync {
    async fn emit(&self, event_type: &str, ctx: &TraceContext, payload: serde_json::Value) -> Result<(), Box<dyn std::error::Error>>;
}

// ── Redis-baserad implementation ──────────────────────────────────────────
pub struct RedisHermes {
    client: redis::aio::MultiplexedConnection,
    channel: String,
}

impl RedisHermes {
    pub async fn new(redis_url: &str, channel: &str) -> Result<Self, redis::RedisError> {
        let client = redis::Client::open(redis_url)?;
        let conn = client.get_multiplexed_tokio_connection().await?;
        Ok(Self { client: conn, channel: channel.to_string() })
    }
}

#[async_trait::async_trait]
impl HermesClient for RedisHermes {
    async fn emit(&self, event_type: &str, ctx: &TraceContext, payload: serde_json::Value) -> Result<(), Box<dyn std::error::Error>> {
        let event = HermesEvent {
            event_type: event_type.to_string(),
            trace_id: ctx.trace_id.to_string(),
            correlation_id: ctx.correlation_id.to_string(),
            tenant_id: ctx.tenant_id.0.clone(),
            user_id: ctx.user_id.clone(),
            payload,
            timestamp: chrono::Utc::now(),
        };
        let json = serde_json::to_string(&event)?;
        redis::cmd("PUBLISH")
            .arg(&self.channel)
            .arg(json)
            .query_async::<_, ()>(&mut self.client.clone())
            .await?;
        Ok(())
    }
}

2. API-endpoints (REST API)

2.1 Axum-router-struktur

// crates/aamos-api/src/routes/mod.rs

use axum::{
    routing::{get, post, put, delete},
    Router,
};
use std::sync::Arc;

pub mod accounts;
pub mod journal;
pub mod periods;
pub mod reports;
pub mod export;
pub mod customers;
pub mod invoices;
pub mod health;
pub mod auth;

use crate::AppState;

pub fn create_router(state: Arc<AppState>) -> Router {
    Router::new()
        // Health (unauthed)
        .route("/health", get(health::health_check))
        .route("/api/auth/validate", get(auth::validate_token))
        // Accounts
        .route("/api/ledger/accounts", get(accounts::list_accounts).post(accounts::create_account))
        .route("/api/ledger/accounts/:id", get(accounts::get_account).put(accounts::update_account).delete(accounts::delete_account))
        // Journal entries
        .route("/api/ledger/journal", get(journal::list_entries).post(journal::create_entry))
        .route("/api/ledger/journal/:id", get(journal::get_entry).put(journal::update_entry).delete(journal::delete_entry))
        .route("/api/ledger/journal/:id/post", post(journal::post_entry))
        .route("/api/ledger/journal/:id/void", post(journal::void_entry))
        .route("/api/ledger/journal/:id/upload", post(journal::upload_attachment))
        .route("/api/ledger/journal/:id/receipt", post(journal::add_receipt))
        .route("/api/ledger/journal/:id/receipts", get(journal::list_receipts))
        // Periods
        .route("/api/ledger/periods", get(periods::list_periods).post(periods::create_period))
        .route("/api/ledger/periods/:period", get(periods::get_period))
        .route("/api/ledger/periods/:period/open", post(periods::open_period))
        .route("/api/ledger/periods/:period/submit-review", post(periods::submit_review))
        .route("/api/ledger/periods/:period/approve", post(periods::approve_period))
        .route("/api/ledger/periods/:period/reject", post(periods::reject_period))
        .route("/api/ledger/periods/:period/close", post(periods::close_period))
        .route("/api/ledger/periods/:period/reopen", post(periods::reopen_period))
        // Reports
        .route("/api/ledger/reports/balance", get(reports::balance_sheet))
        .route("/api/ledger/reports/income", get(reports::income_statement))
        .route("/api/ledger/reports/transactions", get(reports::transaction_report))
        .route("/api/ledger/reports/trial-balance", get(reports::trial_balance))
        // Export
        .route("/api/ledger/export/sie4", get(export::export_sie4))
        .route("/api/ledger/export/sie4/preview", get(export::preview_sie4))
        .route("/api/ledger/export/csv", get(export::export_csv))
        // Customers
        .route("/api/ledger/customers", get(customers::list_customers).post(customers::create_customer))
        .route("/api/ledger/customers/:id", get(customers::get_customer).put(customers::update_customer))
        // Invoices
        .route("/api/ledger/invoices", get(invoices::list_invoices))
        .route("/api/ledger/invoices/:id", get(invoices::get_invoice))
        // Reconciliation
        .route("/api/ledger/reconciliation/sessions", get(reconciliation::list_sessions).post(reconciliation::create_session))
        .route("/api/ledger/reconciliation/sessions/:id", get(reconciliation::get_session))
        .route("/api/ledger/reconciliation/sessions/:id/transactions", post(reconciliation::add_transactions))
        .route("/api/ledger/reconciliation/sessions/:id/complete", post(reconciliation::complete_session))
        // Bank import
        .route("/api/ledger/bank/import", post(bank_import::import_transactions))
        // Tax
        .route("/api/ledger/tax/moms", get(tax::moms_report))
        .route("/api/ledger/tax/agd", get(tax::agd_report))
        // Payroll
        .route("/api/ledger/payroll/runs", get(payroll::list_runs).post(payroll::create_run))
        .route("/api/ledger/payroll/runs/:id", get(payroll::get_run))
        .route("/api/ledger/payroll/runs/:id/process", post(payroll::process_run))
        .with_state(state)
}

2.2 API-kontrakt (JSON)

POST /api/ledger/journal — Skapa verifikation

Request:

{
  "tenant_id": "wavult-group",
  "entry_date": "2026-01-15",
  "description": "Faktura #1234 - Konsulttjänster",
  "reference": "1234",
  "source_type": "manual",
  "lines": [
    {
      "account_number": "1510",
      "debit": 125000,
      "description": "Kundfordran"
    },
    {
      "account_number": "3001",
      "credit": 100000,
      "description": "Konsultintäkter"
    },
    {
      "account_number": "2611",
      "credit": 25000,
      "description": "Utgående moms 25%"
    }
  ]
}

Response (201):

{
  "ok": true,
  "entry": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "entry_number": 42,
    "fiscal_year": 2026,
    "period": "2026-01",
    "entry_date": "2026-01-15",
    "description": "Faktura #1234 - Konsulttjänster",
    "reference": "1234",
    "source_type": "manual",
    "status": "draft",
    "lines": [...],
    "created_at": "2026-01-15T10:30:00Z"
  }
}

POST /api/ledger/journal/:id/post — Bokför verifikation

Response (200):

{
  "ok": true,
  "entry": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "status": "posted",
    "posted_at": "2026-01-15T10:35:00Z"
  }
}

GET /api/ledger/reports/balance — Balansrapport

Query params: period=2026-01

Response (200):

{
  "ok": true,
  "report": {
    "period": "2026-01",
    "assets": [
      { "account_number": "1910", "account_name": "Kassa", "balance": 50000 },
      { "account_number": "1510", "account_name": "Kundfordringar", "balance": 125000 }
    ],
    "liabilities": [
      { "account_number": "2010", "account_name": "Leverantörsskulder", "balance": 25000 }
    ],
    "equity": [
      { "account_number": "2011", "account_name": "Eget kapital", "balance": 150000 }
    ],
    "total_assets": 175000,
    "total_liabilities": 25000,
    "total_equity": 150000
  }
}

GET /api/ledger/export/sie4 — SIE4-export

Query params: fiscal_year=2026&period=2026-01

Response: Content-Type: text/plain (SIE4-fil)


3. Databasschema

3.1 Migrationer (SQLx)

-- migrations/001_initial_schema.sql
-- ═══════════════════════════════════════════════════════════════════════════
-- AAMOS Ledger Rust — PostgreSQL Schema
-- Principer: Audit First · Tenant Isolation · Deterministic · Reproducible
-- ═══════════════════════════════════════════════════════════════════════════

-- ── Enable extensions ───────────────────────────────────────────────────────
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

-- ── Chart of Accounts (Kontoplan) ───────────────────────────────────────────
CREATE TABLE ledger_accounts (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id       TEXT NOT NULL,
  account_number  TEXT NOT NULL,
  name            TEXT NOT NULL,
  account_type    TEXT NOT NULL CHECK (account_type IN ('asset','liability','equity','revenue','expense')),
  normal_balance  TEXT NOT NULL CHECK (normal_balance IN ('debit','credit')),
  coa_standard    TEXT NOT NULL DEFAULT 'BAS' CHECK (coa_standard IN ('BAS','IFRS','USGAAP','custom')),
  parent_account  TEXT,
  vat_code        TEXT,
  is_active       BOOLEAN NOT NULL DEFAULT TRUE,
  metadata        JSONB NOT NULL DEFAULT '{}',
  created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  UNIQUE (tenant_id, account_number, coa_standard)
);

-- ── Journal Entries (Verifikationer) ────────────────────────────────────────
CREATE TABLE ledger_journal_entries (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id       TEXT NOT NULL,
  entry_number    BIGINT,
  fiscal_year     INTEGER NOT NULL,
  period          TEXT NOT NULL,
  entry_date      DATE NOT NULL,
  description     TEXT NOT NULL,
  reference       TEXT,
  source_type     TEXT NOT NULL CHECK (source_type IN ('manual','import_sie','import_csv','bank','system','agent','opening_balance')),
  source_id       TEXT,
  status          TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft','posted','voided')),
  void_reason     TEXT,
  voided_at       TIMESTAMPTZ,
  voided_by       TEXT,
  trace_id        UUID NOT NULL,
  correlation_id  UUID NOT NULL,
  user_id         TEXT NOT NULL,
  decision_source TEXT NOT NULL DEFAULT 'user' CHECK (decision_source IN ('user','system','agent')),
  metadata        JSONB NOT NULL DEFAULT '{}',
  created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  posted_at       TIMESTAMPTZ
);

CREATE SEQUENCE ledger_entry_number_seq START 1;

-- ── Journal Lines (Verifikationsrader) ──────────────────────────────────────
CREATE TABLE ledger_journal_lines (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  entry_id        UUID NOT NULL REFERENCES ledger_journal_entries(id) ON DELETE CASCADE,
  tenant_id       TEXT NOT NULL,
  line_number     INTEGER NOT NULL,
  account_number  TEXT NOT NULL,
  account_name    TEXT,
  debit           BIGINT,  -- i ören (heltal)
  credit          BIGINT,  -- i ören (heltal)
  currency        TEXT NOT NULL DEFAULT 'SEK',
  amount_base     BIGINT,
  vat_code        TEXT,
  vat_amount      BIGINT,
  cost_center     TEXT,
  project_code    TEXT,
  description     TEXT,
  metadata        JSONB NOT NULL DEFAULT '{}',
  CONSTRAINT chk_debit_credit CHECK (
    (debit IS NOT NULL AND credit IS NULL) OR
    (debit IS NULL AND credit IS NOT NULL)
  ),
  CONSTRAINT chk_positive_debit  CHECK (debit  IS NULL OR debit  > 0),
  CONSTRAINT chk_positive_credit CHECK (credit IS NULL OR credit > 0)
);

-- ── Audit Log ────────────────────────────────────────────────────────────────
CREATE TABLE ledger_audit_log (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id       TEXT NOT NULL,
  trace_id        UUID NOT NULL,
  correlation_id  UUID NOT NULL,
  user_id         TEXT NOT NULL,
  entity_type     TEXT NOT NULL,
  entity_id       TEXT NOT NULL,
  action          TEXT NOT NULL,
  decision_source TEXT NOT NULL,
  before_state    JSONB,
  after_state     JSONB,
  ip_address      TEXT,
  session_id      TEXT,
  ts              TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- ── Periods ──────────────────────────────────────────────────────────────────
CREATE TABLE ledger_periods (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id       TEXT NOT NULL,
  fiscal_year     INTEGER NOT NULL,
  period          TEXT NOT NULL,
  status          TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','review','approved','closed')),
  opened_at       TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  closed_at       TIMESTAMPTZ,
  closed_by       TEXT,
  workflow_id     UUID,
  trial_balance   JSONB,
  trace_id        UUID NOT NULL,
  metadata        JSONB NOT NULL DEFAULT '{}',
  UNIQUE (tenant_id, fiscal_year, period)
);

-- ── Reconciliation Sessions ──────────────────────────────────────────────────
CREATE TABLE ledger_reconciliation_sessions (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id       TEXT NOT NULL,
  account_number  TEXT NOT NULL,
  period          TEXT NOT NULL,
  fiscal_year     INTEGER NOT NULL,
  status          TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','in_progress','completed','cancelled')),
  bank_balance    BIGINT NOT NULL,
  ledger_balance  BIGINT NOT NULL,
  difference      BIGINT NOT NULL,
  trace_id        UUID NOT NULL,
  metadata        JSONB NOT NULL DEFAULT '{}',
  created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  completed_at    TIMESTAMPTZ
);

-- ── Bank Transactions ────────────────────────────────────────────────────────
CREATE TABLE ledger_bank_transactions (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id       TEXT NOT NULL,
  session_id      UUID REFERENCES ledger_reconciliation_sessions(id),
  transaction_id  TEXT NOT NULL,
  account_number  TEXT NOT NULL,
  transaction_date DATE NOT NULL,
  description     TEXT NOT NULL,
  amount          BIGINT NOT NULL,
  currency        TEXT NOT NULL DEFAULT 'SEK',
  reference       TEXT,
  counterparty    TEXT,
  matched_entry_id UUID,
  raw_data        JSONB NOT NULL DEFAULT '{}',
  created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  UNIQUE (tenant_id, transaction_id)
);

-- ── Customers ────────────────────────────────────────────────────────────────
CREATE TABLE ledger_customers (
  id                  UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id           TEXT NOT NULL,
  name                TEXT NOT NULL,
  org_number          TEXT,
  vat_number          TEXT,
  contact_name        TEXT,
  contact_email       TEXT,
  contact_phone       TEXT,
  address             TEXT,
  postal_code         TEXT,
  city                TEXT,
  country             TEXT NOT NULL DEFAULT 'SE',
  payment_terms_days  INTEGER NOT NULL DEFAULT 30,
  default_vat_rate    INTEGER NOT NULL DEFAULT 25,
  default_account     TEXT NOT NULL DEFAULT '3000',
  currency            TEXT NOT NULL DEFAULT 'SEK',
  notes               TEXT,
  active              BOOLEAN NOT NULL DEFAULT TRUE,
  created_at          TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at          TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- ── Contracts ────────────────────────────────────────────────────────────────
CREATE TABLE ledger_contracts (
  id                  UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  customer_id         UUID NOT NULL REFERENCES ledger_customers(id) ON DELETE CASCADE,
  title               TEXT NOT NULL,
  contract_ref        TEXT,
  amount_excl_vat     BIGINT NOT NULL,
  vat_rate            INTEGER NOT NULL DEFAULT 25,
  billing_period      TEXT NOT NULL DEFAULT 'monthly',
  payment_terms_days  INTEGER NOT NULL DEFAULT 30,
  invoice_description TEXT,
  account_credit      TEXT NOT NULL DEFAULT '3000',
  status              TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','paused','terminated')),
  start_date          DATE,
  end_date            DATE,
  created_at          TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at          TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- ── Payroll Runs ─────────────────────────────────────────────────────────────
CREATE TABLE ledger_payroll_runs (
  id                  UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id           TEXT NOT NULL,
  period              TEXT NOT NULL,
  run_date            DATE NOT NULL,
  status              TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft','approved','processed','paid')),
  total_gross         BIGINT NOT NULL DEFAULT 0,
  total_tax           BIGINT NOT NULL DEFAULT 0,
  total_net           BIGINT NOT NULL DEFAULT 0,
  total_employer_fees BIGINT NOT NULL DEFAULT 0,
  journal_entry_id    UUID,
  trace_id            UUID NOT NULL,
  created_at          TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at          TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- ── Payroll Employees ────────────────────────────────────────────────────────
CREATE TABLE ledger_payroll_employees (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  run_id          UUID NOT NULL REFERENCES ledger_payroll_runs(id) ON DELETE CASCADE,
  employee_id     TEXT NOT NULL,
  name            TEXT NOT NULL,
  gross_salary    BIGINT NOT NULL,
  tax_deduction   BIGINT NOT NULL,
  net_salary      BIGINT NOT NULL,
  employer_fees   BIGINT NOT NULL,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- ── Indexes ───────────────────────────────────────────────────────────────────
CREATE INDEX idx_journal_tenant_period ON ledger_journal_entries (tenant_id, fiscal_year, period);
CREATE INDEX idx_journal_status ON ledger_journal_entries (tenant_id, status);
CREATE INDEX idx_journal_date ON ledger_journal_entries (tenant_id, entry_date);
CREATE INDEX idx_journal_reference ON ledger_journal_entries (tenant_id, reference);
CREATE INDEX idx_lines_entry ON ledger_journal_lines (entry_id);
CREATE INDEX idx_lines_account ON ledger_journal_lines (tenant_id, account_number);
CREATE INDEX idx_audit_entity ON ledger_audit_log (tenant_id, entity_type, entity_id);
CREATE INDEX idx_audit_trace ON ledger_audit_log (trace_id);
CREATE INDEX idx_accounts_tenant ON ledger_accounts (tenant_id, account_number);
CREATE INDEX idx_periods_tenant ON ledger_periods (tenant_id, fiscal_year, period);
CREATE INDEX idx_recon_session ON ledger_reconciliation_sessions (tenant_id, account_number, period);
CREATE INDEX idx_bank_tx_account ON ledger_bank_transactions (tenant_id, account_number, transaction_date);
CREATE INDEX idx_customers_tenant ON ledger_customers (tenant_id, active);
CREATE INDEX idx_contracts_customer ON ledger_contracts (customer_id, status);
CREATE INDEX idx_payroll_period ON ledger_payroll_runs (tenant_id, period);

-- ── Updated_at triggers ───────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
  NEW.updated_at = NOW();
  RETURN NEW;
END;
$$ language 'plpgsql';

CREATE TRIGGER update_accounts_updated_at BEFORE UPDATE ON ledger_accounts
  FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_customers_updated_at BEFORE UPDATE ON ledger_customers
  FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_contracts_updated_at BEFORE UPDATE ON ledger_contracts
  FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_payroll_runs_updated_at BEFORE UPDATE ON ledger_payroll_runs
  FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();

3.2 Indexering och prestanda

Tabell Index Syfte
ledger_journal_entries (tenant_id, fiscal_year, period) Periodbaserade rapporter
ledger_journal_entries (tenant_id, status) Filtrera på status
ledger_journal_entries (tenant_id, entry_date) Datumintervall-sökningar
ledger_journal_entries (tenant_id, reference) Sök på referens
ledger_journal_lines (entry_id) JOIN med entries
ledger_journal_lines (tenant_id, account_number) Kontobaserade rapporter
ledger_audit_log (tenant_id, entity_type, entity_id) Entity-historik
ledger_audit_log (trace_id) Trace-sökning
ledger_bank_transactions (tenant_id, account_number, transaction_date) Bankimport-sökning
ledger_reconciliation_sessions (tenant_id, account_number, period) Reconciliation-lookup

4. Säkerhet

4.1 Input-validering

// crates/aamos-api/src/validation.rs

use aamos_core::{LedgerError, LedgerResult};
use garde::Validate;

#[derive(Debug, Validate, Deserialize)]
pub struct CreateJournalEntryRequest {
    #[garde(length(min = 1, max = 500))]
    pub description: String,
    #[garde(custom(validate_date))]
    pub entry_date: String,
    #[garde(length(min = 1))]
    pub lines: Vec<JournalLineRequest>,
}

#[derive(Debug, Validate, Deserialize)]
pub struct JournalLineRequest {
    #[garde(length(min = 1, max = 20))]
    pub account_number: String,
    #[garde(skip)]  // Valideras separat (antingen debit ELLER kredit)
    pub debit: Option<i64>,
    #[garde(skip)]
    pub credit: Option<i64>,
}

fn validate_date(date: &str, _ctx: &()) -> garde::Result {
    chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d")
        .map_err(|_| garde::Error::new("Invalid date format, expected YYYY-MM-DD"))?
    Ok(())
}

4.2 RBAC (Rollbaserad åtkomstkontroll)

// crates/aamos-api/src/middleware/rbac.rs

use aamos_auth::{AuthUser, Role};
use axum::{
    extract::Request,
    http::StatusCode,
    middleware::Next,
    response::Response,
};

pub async fn require_roles(
    roles: Vec<Role>,
    auth_user: AuthUser,
    request: Request,
    next: Next,
) -> Result<Response, StatusCode> {
    if !auth_user.has_any_role(&roles) {
        return Err(StatusCode::FORBIDDEN);
    }
    Ok(next.run(request).await)
}

// Macro för enkel RBAC på routes
#[macro_export]
macro_rules! require_role {
    ($($role:ident),+) => {
        axum::middleware::from_fn(move |auth_user: aamos_auth::AuthUser, req, next| {
            require_roles(vec![$(aamos_auth::Role::$role),+], auth_user, req, next)
        })
    };
}

4.3 RBAC-matris

Endpoint Metod Roller
/api/ledger/accounts POST admin, accountant
/api/ledger/accounts/:id PUT/DELETE admin
/api/ledger/journal POST admin, accountant
/api/ledger/journal/:id/post POST admin, accountant
/api/ledger/journal/:id/void POST admin
/api/ledger/journal/:id/upload POST admin, accountant
/api/ledger/periods/:period/* POST admin
/api/ledger/customers POST admin, accountant
/api/ledger/export/sie4 GET admin, accountant, viewer
/api/ledger/reports/* GET admin, accountant, viewer, auditor
/api/ledger/reconciliation/* ALL admin, accountant
/api/ledger/bank/import POST admin, accountant
/api/ledger/tax/* GET admin, accountant
/api/ledger/payroll/* ALL admin, payroll

4.4 Audit-logging

Alla skrivoperationer loggas automatiskt via middleware:

// crates/aamos-api/src/middleware/audit.rs

use aamos_audit::AuditWriter;
use aamos_core::TraceContext;
use axum::{
    extract::Request,
    middleware::Next,
    response::Response,
};
use std::sync::Arc;

pub async fn audit_middleware(
    ctx: TraceContext,
    audit_writer: Arc<AuditWriter>,
    request: Request,
    next: Next,
) -> Response {
    let method = request.method().to_string();
    let path = request.uri().path().to_string();
    
    let response = next.run(request).await;
    
    // Logga endast muterande operationer
    if method != "GET" && method != "HEAD" {
        let action = format!("{} {}", method, path);
        let _ = audit_writer.write(
            &ctx,
            &action,
            None, // before_state kan hämtas från request body
            None, // after_state kan hämtas från response
        ).await;
    }
    
    response
}

5. Integrationer

5.1 Bankimport — Nordea

// crates/aamos-bank-import/src/nordea.rs

use aamos_core::{LedgerError, LedgerResult, Money, TenantId};
use aamos_bank_import::{BankImporter, BankTransaction};
use chrono::NaiveDate;
use reqwest::Client;

pub struct NordeaImporter {
    client: Client,
    api_base: String,
    client_id: String,
    client_secret: String,
}

impl NordeaImporter {
    pub fn new(api_base: String, client_id: String, client_secret: String) -> Self {
        Self {
            client: Client::new(),
            api_base,
            client_id,
            client_secret,
        }
    }

    async fn get_access_token(&self) -> LedgerResult<String> {
        // OAuth2 client credentials flow
        let response = self.client
            .post(format!("{}/v2/oauth/token", self.api_base))
            .basic_auth(&self.client_id, Some(&self.client_secret))
            .form(&[("grant_type", "client_credentials")])
            .send()
            .await
            .map_err(|e| LedgerError::Internal(e.to_string()))?;
        
        let token: serde_json::Value = response.json().await
            .map_err(|e| LedgerError::Internal(e.to_string()))?;
        
        token["access_token"]
            .as_str()
            .map(|s| s.to_string())
            .ok_or_else(|| LedgerError::Internal("No access token".to_string()))
    }
}

#[async_trait::async_trait]
impl BankImporter for NordeaImporter {
    async fn fetch_transactions(
        &self,
        _tenant_id: &TenantId,
        account_number: &str,
        from_date: NaiveDate,
        to_date: NaiveDate,
    ) -> LedgerResult<Vec<BankTransaction>> {
        let token = self.get_access_token().await?;
        
        let response = self.client
            .get(format!("{}/v2/accounts/{}/transactions", self.api_base, account_number))
            .bearer_auth(token)
            .query(&[
                ("fromDate", from_date.to_string()),
                ("toDate", to_date.to_string()),
            ])
            .send()
            .await
            .map_err(|e| LedgerError::Internal(e.to_string()))?;
        
        let data: serde_json::Value = response.json().await
            .map_err(|e| LedgerError::Internal(e.to_string()))?;
        
        let transactions: Vec<BankTransaction> = data["transactions"]
            .as_array()
            .unwrap_or(&vec![])
            .iter()
            .filter_map(|t| {
                Some(BankTransaction {
                    transaction_id: t["transactionId"].as_str()?.to_string(),
                    account_number: account_number.to_string(),
                    transaction_date: NaiveDate::parse_from_str(
                        t["bookingDate"].as_str()?,
                        "%Y-%m-%d"
                    ).ok()?,
                    description: t["remittanceInformation"].as_str()?.to_string(),
                    amount: Money::from_decimal(t["amount"].as_f64()?),
                    currency: t["currency"].as_str()?.to_string(),
                    reference: t["referenceNumber"].as_str().map(|s| s.to_string()),
                    counterparty: t["creditorName"].as_str().map(|s| s.to_string()),
                    raw_data: t.clone(),
                })
            })
            .collect();
        
        Ok(transactions)
    }
}

5.2 Bankimport — Revolut

// crates/aamos-bank-import/src/revolut.rs

use aamos_core::{LedgerError, LedgerResult, Money, TenantId};
use aamos_bank_import::{BankImporter, BankTransaction};
use chrono::NaiveDate;
use reqwest::Client;

pub struct RevolutImporter {
    client: Client,
    api_base: String,
    api_key: String,
}

impl RevolutImporter {
    pub fn new(api_base: String, api_key: String) -> Self {
        Self {
            client: Client::new(),
            api_base,
            api_key,
        }
    }
}

#[async_trait::async_trait]
impl BankImporter for RevolutImporter {
    async fn fetch_transactions(
        &self,
        _tenant_id: &TenantId,
        account_id: &str,
        from_date: NaiveDate,
        to_date: NaiveDate,
    ) -> LedgerResult<Vec<BankTransaction>> {
        let response = self.client
            .get(format!("{}/api/1.0/accounts/{}/transactions", self.api_base, account_id))
            .header("Authorization", format!("Bearer {}", self.api_key))
            .query(&[
                ("from", from_date.to_string()),
                ("to", to_date.to_string()),
            ])
            .send()
            .await
            .map_err(|e| LedgerError::Internal(e.to_string()))?;
        
        let data: serde_json::Value = response.json().await
            .map_err(|e| LedgerError::Internal(e.to_string()))?;
        
        let transactions: Vec<BankTransaction> = data["data"]
            .as_array()
            .unwrap_or(&vec![])
            .iter()
            .filter_map(|t| {
                Some(BankTransaction {
                    transaction_id: t["id"].as_str()?.to_string(),
                    account_number: account_id.to_string(),
                    transaction_date: NaiveDate::parse_from_str(
                        t["created_at"].as_str()?.split('T').next()?,
                        "%Y-%m-%d"
                    ).ok()?,
                    description: t["description"].as_str()?.to_string(),
                    amount: Money::from_decimal(t["amount"].as_f64()?),
                    currency: t["currency"].as_str()?.to_string(),
                    reference: t["reference"].as_str().map(|s| s.to_string()),
                    counterparty: t["merchant"]["name"].as_str().map(|s| s.to_string()),
                    raw_data: t.clone(),
                })
            })
            .collect();
        
        Ok(transactions)
    }
}

5.3 Skatteverket — AGD

// crates/aamos-tax/src/agd.rs

use aamos_core::{LedgerResult, Money, Period, TenantId};
use aamos_tax::AgdReport;
use sqlx::PgPool;

pub async fn generate_agd_report(
    pool: &PgPool,
    tenant_id: &TenantId,
    period: &Period,
) -> LedgerResult<AgdReport> {
    let rows = sqlx::query!(
        r#"
        SELECT
          l.account_number,
          COALESCE(SUM(l.debit), 0) as total_debit,
          COALESCE(SUM(l.credit), 0) as total_credit
        FROM ledger_journal_lines l
        JOIN ledger_journal_entries e ON e.id = l.entry_id
        WHERE e.tenant_id = $1
          AND e.period = $2
          AND e.status = 'posted'
          AND l.account_number IN ('7010', '7510', '2012', '2013')
        GROUP BY l.account_number
        "#,
        tenant_id.0,
        period.to_string()
    )
    .fetch_all(pool)
    .await?;

    let mut arbetsgivaravgift = Money::zero();
    let mut avdragen_skatt = Money::zero();
    let mut sociala_avgifter = Money::zero();

    for row in rows {
        let debit = Money(row.total_debit.unwrap_or(0));
        let credit = Money(row.total_credit.unwrap_or(0));
        match row.account_number.as_str() {
            "7510" => arbetsgivaravgift = debit - credit,
            "2012" => avdragen_skatt = credit - debit,
            "2013" => sociala_avgifter = credit - debit,
            _ => {}
        }
    }

    Ok(AgdReport {
        tenant_id: tenant_id.clone(),
        period: period.clone(),
        arbetsgivaravgift,
        avdragen_skatt,
        sociala_avgifter,
        detaljer: vec![
            // ...detaljer per avgiftskod
        ],
    })
}

5.4 Skatteverket — Moms

// crates/aamos-tax/src/moms.rs

use aamos_core::{LedgerResult, Money, Period, TenantId};
use aamos_tax::{MomsDetalj, MomsReport};
use sqlx::PgPool;

pub async fn generate_moms_report(
    pool: &PgPool,
    tenant_id: &TenantId,
    period: &Period,
) -> LedgerResult<MomsReport> {
    // Moms utgående (konton 2611, 2614, 2615, 2616)
    let utgaende_rows = sqlx::query!(
        r#"
        SELECT
          l.account_number,
          COALESCE(SUM(l.credit), 0) - COALESCE(SUM(l.debit), 0) as net_amount
        FROM ledger_journal_lines l
        JOIN ledger_journal_entries e ON e.id = l.entry_id
        WHERE e.tenant_id = $1
          AND e.period = $2
          AND e.status = 'posted'
          AND l.account_number LIKE '26%'
        GROUP BY l.account_number
        "#,
        tenant_id.0,
        period.to_string()
    )
    .fetch_all(pool)
    .await?;

    // Moms ingående (konto 2640)
    let ingaende_row = sqlx::query!(
        r#"
        SELECT COALESCE(SUM(l.debit), 0) - COALESCE(SUM(l.credit), 0) as net_amount
        FROM ledger_journal_lines l
        JOIN ledger_journal_entries e ON e.id = l.entry_id
        WHERE e.tenant_id = $1
          AND e.period = $2
          AND e.status = 'posted'
          AND l.account_number = '2640'
        "#,
        tenant_id.0,
        period.to_string()
    )
    .fetch_one(pool)
    .await?;

    let moms_utgaende: Money = utgaende_rows.iter().map(|r| Money(r.net_amount.unwrap_or(0))).fold(Money::zero(), |a, b| a + b);
    let moms_ingaende = Money(ingaende_row.net_amount.unwrap_or(0));
    let moms_att_betala = moms_utgaende - moms_ingaende;

    let detaljer: Vec<MomsDetalj> = utgaende_rows.iter().map(|r| {
        let (kod, beskrivning) = match r.account_number.as_str() {
            "2611" => ("MP1", "Utgående moms 25%"),
            "2614" => ("MP2", "Utgående moms 12%"),
            "2615" => ("MP3", "Utgående moms 6%"),
            _ => ("MP0", "Övrig utgående moms"),
        };
        MomsDetalj {
            moms_kod: kod.to_string(),
            beskrivning: beskrivning.to_string(),
            belopp: Money(r.net_amount.unwrap_or(0)),
        }
    }).collect();

    Ok(MomsReport {
        tenant_id: tenant_id.clone(),
        period: period.clone(),
        moms_utgaende,
        moms_ingaende,
        moms_att_betala,
        detaljer,
    })
}

6. Huvudapplikation (aamos-api)

// crates/aamos-api/src/main.rs

use aamos_audit::AuditWriter;
use aamos_auth::JwtValidator;
use aamos_db::{create_pool, run_migrations};
use aamos_hermes::RedisHermes;
use axum::{
    middleware,
    Router,
};
use std::sync::Arc;
use tokio::net::TcpListener;
use tower_http::{
    cors::CorsLayer,
    trace::TraceLayer,
};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};

mod middleware;
mod routes;
mod validation;

#[derive(Clone)]
pub struct AppState {
    pub pool: sqlx::PgPool,
    pub jwt_validator: Arc<JwtValidator>,
    pub audit_writer: Arc<AuditWriter>,
    pub hermes: Arc<dyn aamos_hermes::HermesClient>,
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Initiera tracing
    tracing_subscriber::registry()
        .with(tracing_subscriber::EnvFilter::new(
            std::env::var("RUST_LOG").unwrap_or_else(|_| "aamos_api=debug,tower_http=debug".into()),
        ))
        .with(tracing_subscriber::fmt::layer())
        .init();

    // Ladda konfiguration
    let database_url = std::env::var("DATABASE_URL")?;
    let jwt_secret = std::env::var("AMOS_JWT_SECRET")
        .or_else(|_| std::env::var("JWT_SECRET"))?;
    let redis_url = std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://localhost:6379".to_string());
    let port = std::env::var("AAMOS_LEDGER_PORT")
        .unwrap_or_else(|_| "3250".to_string())
        .parse::<u16>()?;

    // Databas
    let pool = create_pool(&database_url).await?;
    run_migrations(&pool).await?;
    tracing::info!("Database connected and migrations applied");

    // JWT-validator
    let jwt_validator = Arc::new(JwtValidator::from_secret(&jwt_secret));

    // Audit-writer
    let audit_writer = Arc::new(AuditWriter::new(pool.clone()));

    // Hermes (Redis)
    let hermes = Arc::new(RedisHermes::new(&redis_url, "aamos-events").await?);

    // App state
    let state = Arc::new(AppState {
        pool,
        jwt_validator,
        audit_writer,
        hermes,
    });

    // Router
    let app = routes::create_router(state)
        .layer(TraceLayer::new_for_http())
        .layer(CorsLayer::permissive());

    // Starta server
    let listener = TcpListener::bind(format!("0.0.0.0:{}", port)).await?;
    tracing::info!("AAMOS Ledger Rust listening on port {}", port);
    axum::serve(listener, app).await?;

    Ok(())
}

7. Testning

7.1 Enhetstester

// crates/aamos-ledger/src/models.rs (tester)

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_journal_entry_balanced() {
        let entry = JournalEntry {
            id: Uuid::new_v4(),
            tenant_id: TenantId::new("test"),
            entry_number: Some(1),
            fiscal_year: 2026,
            period: Period::new(2026, 1).unwrap(),
            entry_date: NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
            description: "Test".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".to_string(),
            decision_source: DecisionSource::User,
            metadata: serde_json::Value::Null,
            created_at: Utc::now(),
            posted_at: None,
            lines: vec![
                JournalLine {
                    id: Uuid::new_v4(),
                    entry_id: Uuid::new_v4(),
                    tenant_id: TenantId::new("test"),
                    line_number: 1,
                    account_number: "1910".to_string(),
                    account_name: None,
                    debit: Some(Money(10000)),
                    credit: None,
                    currency: Currency::SEK,
                    amount_base: None,
                    vat_code: None,
                    vat_amount: None,
                    cost_center: None,
                    project_code: None,
                    description: None,
                    metadata: serde_json::Value::Null,
                },
                JournalLine {
                    id: Uuid::new_v4(),
                    entry_id: Uuid::new_v4(),
                    tenant_id: TenantId::new("test"),
                    line_number: 2,
                    account_number: "3000".to_string(),
                    account_name: None,
                    debit: None,
                    credit: Some(Money(10000)),
                    currency: Currency::SEK,
                    amount_base: None,
                    vat_code: None,
                    vat_amount: None,
                    cost_center: None,
                    project_code: None,
                    description: None,
                    metadata: serde_json::Value::Null,
                },
            ],
        };

        assert!(entry.is_balanced());
        assert!(entry.validate_lines().is_ok());
    }

    #[test]
    fn test_journal_entry_unbalanced() {
        let entry = JournalEntry {
            // ...
            lines: vec![
                JournalLine { debit: Some(Money(10000)), credit: None, /* ... */ },
                JournalLine { debit: None, credit: Some(Money(5000)), /* ... */ },
            ],
        };

        assert!(!entry.is_balanced());
    }
}

7.2 Integrationstester

// tests/integration/journal_api_test.rs

use aamos_api::AppState;
use axum::{
    body::Body,
    http::{Request, StatusCode},
};
use serde_json::json;
use tower::ServiceExt;

#[tokio::test]
async fn test_create_journal_entry() {
    let app = create_test_app().await;

    let response = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/ledger/journal")
                .header("Content-Type", "application/json")
                .header("Authorization", "Bearer test-token")
                .body(Body::from(
                    json!({
                        "description": "Test entry",
                        "entry_date": "2026-01-15",
                        "lines": [
                            {"account_number": "1910", "debit": 10000},
                            {"account_number": "3000", "credit": 10000}
                        ]
                    }).to_string(),
                ))
                .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(response.status(), StatusCode::CREATED);
}

8. Deployment

8.1 Dockerfile

# Build stage
FROM rust:1.82-slim-bookworm AS builder
WORKDIR /app
COPY . .
RUN cargo build --release --bin aamos-api

# Runtime stage
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y libpq5 ca-certificates && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /app/target/release/aamos-api /usr/local/bin/
COPY migrations /app/migrations
ENV RUST_LOG=aamos_api=info
EXPOSE 3250
CMD ["aamos-api"]

8.2 docker-compose.yml

version: '3.8'

services:
  aamos-ledger:
    build: .
    ports:
      - "3250:3250"
    environment:
      - DATABASE_URL=postgres://user:pass@postgres:5432/aamos_ledger
      - AMOS_JWT_SECRET=${AMOS_JWT_SECRET}
      - REDIS_URL=redis://redis:6379
      - RUST_LOG=aamos_api=info
    depends_on:
      - postgres
      - redis

  postgres:
    image: postgres:16
    environment:
      - POSTGRES_USER=user
      - POSTGRES_PASSWORD=pass
      - POSTGRES_DB=aamos_ledger
    volumes:
      - postgres_data:/var/lib/postgresql/data
    ports:
      - "5432:5432"

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

volumes:
  postgres_data:

9. Migreringsplan från Node.js till Rust

Fas 1: Parallell drift (månad 1-2)

  1. Bygg Rust-applikationen
  2. Kör båda systemen parallellt
  3. Skriv till båda databaserna (dual-write)
  4. Läs från Node.js (single-read)

Fas 2: Gradvis övergång (månad 3)

  1. Växla läsning till Rust för nya endpoints
  2. Validera datakonsistens
  3. Hantera avvikelser

Fas 3: Full övergång (månad 4)

  1. Stäng av Node.js-skrivningar
  2. Avveckla Node.js-applikationen
  3. Rensa dubbla databaser

10. Prestandamål

Mått Node.js (nuvarande) Rust (mål)
Starttid ~2s <500ms
Minnesanvändning ~200MB <50MB
Latens (p50) ~50ms <10ms
Latens (p99) ~200ms <50ms
Genomströmning ~1000 req/s ~10000 req/s
SIE4-export (1 år) ~5s <1s

11. Sammanfattning

Detta designspecifikation definierar ett komplett Rust-baserat ekonomisystem som ersätter den nuvarande Node.js-versionen. Systemet är uppdelat i modulära crates med tydliga ansvarsområden:

  • aamos-core: Delade typer och utilities
  • aamos-db: Databas-lager med sqlx
  • aamos-auth: JWT och RBAC
  • aamos-ledger: Kärnmodul för verifikat, konton och perioder
  • aamos-sie4: SIE4 import/export
  • aamos-reports: Ekonomiska rapporter
  • aamos-bank-import: Bankintegrationer
  • aamos-tax: Skatteverket-integration
  • aamos-payroll: Lönesystem-integration
  • aamos-api: REST API med Axum
  • aamos-hermes: Event fabric

Systemet behåller alla nuvarande funktioner men med Rusts minnessäkerhet, prestanda och robusthet. Audit-first-principen säkerställer full spårbarhet, och RBAC ger granulär åtkomstkontroll.