bae705aa97
- 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
321 lines
9.6 KiB
Rust
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);
|
|
}
|
|
}
|