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

2256 lines
75 KiB
Markdown

# 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<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.
```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<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).
```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<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.
```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<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).
```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<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.
```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<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.
```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<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.
```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<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.
```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<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.
```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<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.
```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<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
```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<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:**
```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<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)
```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<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:
```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<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
```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<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
```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<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
```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<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
```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<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)
```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<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
```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.