2256 lines
75 KiB
Markdown
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.
|