Files
boc/aamos-ledger-rust/crates/aamos-ledger/src/models.rs
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

321 lines
9.6 KiB
Rust

use aamos_core::{Currency, DecisionSource, Money, Period, TenantId};
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(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_tenant() -> TenantId {
TenantId::new("test-tenant")
}
fn base_entry() -> JournalEntry {
JournalEntry {
id: Uuid::new_v4(),
tenant_id: test_tenant(),
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 entry".to_string(),
reference: None,
source_type: SourceType::Manual,
source_id: None,
status: EntryStatus::Draft,
void_reason: None,
voided_at: None,
voided_by: None,
trace_id: Uuid::new_v4(),
correlation_id: Uuid::new_v4(),
user_id: "test-user".to_string(),
decision_source: DecisionSource::User,
metadata: serde_json::Value::Object(serde_json::Map::new()),
created_at: Utc::now(),
posted_at: None,
lines: vec![],
}
}
fn make_line(debit: Option<i64>, credit: Option<i64>) -> JournalLine {
JournalLine {
id: Uuid::new_v4(),
entry_id: Uuid::new_v4(),
tenant_id: test_tenant(),
line_number: 1,
account_number: "1910".to_string(),
account_name: None,
debit: debit.map(Money),
credit: credit.map(Money),
currency: Currency::SEK,
amount_base: None,
vat_code: None,
vat_amount: None,
cost_center: None,
project_code: None,
description: None,
metadata: serde_json::Value::Object(serde_json::Map::new()),
}
}
#[test]
fn test_journal_entry_balanced() {
let mut entry = base_entry();
entry.lines = vec![
make_line(Some(10000), None), // debit 10 000
make_line(None, Some(10000)), // credit 10 000
];
assert!(entry.is_balanced());
}
#[test]
fn test_journal_entry_unbalanced() {
let mut entry = base_entry();
entry.lines = vec![
make_line(Some(10000), None), // debit 10 000
make_line(None, Some(5000)), // credit 5 000
];
assert!(!entry.is_balanced());
}
#[test]
fn test_validate_lines_ok() {
let mut entry = base_entry();
entry.lines = vec![
make_line(Some(10000), None),
make_line(None, Some(10000)),
];
assert!(entry.validate_lines().is_ok());
}
#[test]
fn test_validate_lines_both_debit_and_credit() {
let mut entry = base_entry();
entry.lines = vec![
make_line(Some(10000), Some(10000)),
];
let result = entry.validate_lines();
assert!(result.is_err());
assert!(result.unwrap_err().contains("both debit and credit"));
}
#[test]
fn test_validate_lines_neither() {
let mut entry = base_entry();
entry.lines = vec![
make_line(None, None),
];
let result = entry.validate_lines();
assert!(result.is_err());
assert!(result.unwrap_err().contains("neither debit nor credit"));
}
#[test]
fn test_period_new_valid() {
let p = Period::new(2026, 6).unwrap();
assert_eq!(p.year, 2026);
assert_eq!(p.month, 6);
}
#[test]
fn test_period_new_invalid_month() {
assert!(Period::new(2026, 0).is_err());
assert!(Period::new(2026, 13).is_err());
}
#[test]
fn test_period_from_str() {
let p = Period::from_str("2026-03").unwrap();
assert_eq!(p.year, 2026);
assert_eq!(p.month, 3);
}
#[test]
fn test_money_operations() {
let a = Money(1000);
let b = Money(500);
assert_eq!((a + b).0, 1500);
assert_eq!((a - b).0, 500);
assert_eq!((-a).0, -1000);
}
#[test]
fn test_money_from_decimal() {
let m = Money::from_decimal(123.45);
assert_eq!(m.0, 12345);
}
#[test]
fn test_money_to_decimal() {
let m = Money(12345);
assert!((m.to_decimal() - 123.45).abs() < 0.001);
}
}