# 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. ```rust // 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) -> 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, pub entity_id: Option, 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 = Result; // ── 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 { 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 { 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. ```rust // crates/aamos-db/src/lib.rs use sqlx::{postgres::PgPoolOptions, PgPool}; use std::time::Duration; pub async fn create_pool(database_url: &str) -> Result { 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). ```rust // 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 { 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, pub exp: usize, pub iat: usize, } // ── AuthUser ────────────────────────────────────────────────────────────── #[derive(Debug, Clone)] pub struct AuthUser { pub user_id: String, pub tenant_id: TenantId, pub roles: HashSet, } 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::>().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 { 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 { let token_data = decode::(token, &self.decoding_key, &self.validation) .map_err(|e| LedgerError::Unauthorized(e.to_string()))?; let claims = token_data.claims; let roles: HashSet = 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. ```rust // 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, pub vat_code: Option, pub is_active: bool, pub metadata: serde_json::Value, pub created_at: DateTime, pub updated_at: DateTime, } #[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, // löpnummer per tenant+räkenskapsår pub fiscal_year: i32, pub period: Period, pub entry_date: NaiveDate, pub description: String, pub reference: Option, pub source_type: SourceType, pub source_id: Option, pub status: EntryStatus, pub void_reason: Option, pub voided_at: Option>, pub voided_by: Option, 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, pub posted_at: Option>, pub lines: Vec, } #[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, pub debit: Option, pub credit: Option, pub currency: Currency, pub amount_base: Option, pub vat_code: Option, pub vat_amount: Option, pub cost_center: Option, pub project_code: Option, pub description: Option, 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, pub closed_at: Option>, pub closed_by: Option, pub workflow_id: Option, pub trial_balance: Option, 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). ```rust // 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(reader: R) -> LedgerResult> { parser::parse_entries(reader) } } // ── SIE4 Exporter ───────────────────────────────────────────────────────── pub struct Sie4Exporter; impl Sie4Exporter { pub fn export( 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. ```rust // 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, pub liabilities: Vec, pub equity: Vec, 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, pub expenses: Vec, 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, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TransactionEntry { pub entry_number: Option, pub entry_date: NaiveDate, pub description: String, pub reference: Option, pub lines: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TransactionLine { pub account_number: String, pub account_name: String, pub debit: Option, pub credit: Option, } ``` ### 1.7 aamos-bank-import — Bankimport **Syfte:** Importera transaktioner från banker. ```rust // 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, pub counterparty: Option, 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>; } // ── Importresultat ──────────────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ImportResult { pub imported_count: usize, pub skipped_count: usize, pub failed_count: usize, pub transactions: Vec, } ``` ### 1.8 aamos-tax — Skatteverket-integration **Syfte:** AGD (arbetsgivardeklaration) och momsredovisning. ```rust // 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, } #[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, } #[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. ```rust // 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, 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 { 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, pub debit: Option, pub credit: Option, } ``` ### 1.10 aamos-audit — Audit-logging **Syfte:** Spårbarhet för alla förändringar. ```rust // 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, pub after_state: Option, pub ip_address: Option, pub session_id: Option, pub ts: DateTime, } // ── 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, after: Option, ) -> 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. ```rust // 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, } // ── 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>; } // ── Redis-baserad implementation ────────────────────────────────────────── pub struct RedisHermes { client: redis::aio::MultiplexedConnection, channel: String, } impl RedisHermes { pub async fn new(redis_url: &str, channel: &str) -> Result { 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> { 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 ```rust // 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) -> 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:** ```json { "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):** ```json { "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):** ```json { "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):** ```json { "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) ```sql -- 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 ```rust // 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, } #[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, #[garde(skip)] pub credit: Option, } 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) ```rust // 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, auth_user: AuthUser, request: Request, next: Next, ) -> Result { 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: ```rust // 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, 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 ```rust // 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 { // 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> { 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 = 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 ```rust // 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> { 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 = 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 ```rust // 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 { 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 ```rust // 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 { // 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 = 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) ```rust // 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, pub audit_writer: Arc, pub hermes: Arc, } #[tokio::main] async fn main() -> Result<(), Box> { // 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::()?; // 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 ```rust // 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 ```rust // 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 ```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 ```yaml 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.