Skip to main content

ringkernel_accnet/models/
journal.rs

1//! Journal entry structures for double-entry bookkeeping.
2//!
3//! A journal entry records a business transaction with balanced debits and credits.
4//! These entries are transformed into network flows using Methods A-E.
5
6use super::{AccountType, Decimal128, HybridTimestamp};
7use rkyv::{Archive, Deserialize, Serialize};
8use uuid::Uuid;
9
10/// The transformation method used to convert a journal entry to flows.
11/// Based on Ivertowski et al. (2024) methodology.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Archive, Serialize, Deserialize)]
13#[archive(compare(PartialEq))]
14#[repr(u8)]
15pub enum SolvingMethod {
16    /// Method A: 1-to-1 mapping (60.68% of entries)
17    /// Single debit → single credit, confidence = 1.0
18    MethodA = 0,
19
20    /// Method B: n-to-n bijective mapping (16.63% of entries)
21    /// Equal debit/credit counts, match by amount
22    /// Confidence = 1.0 (distinct) or 1/n (duplicates)
23    MethodB = 1,
24
25    /// Method C: n-to-m partition (11% of entries)
26    /// Unequal distribution, subset sum or VAT disaggregation
27    /// Confidence varies by match quality
28    MethodC = 2,
29
30    /// Method D: Higher aggregate (11% of entries)
31    /// Account class level matching when detail fails
32    /// Confidence = 1.0 at aggregate level
33    MethodD = 3,
34
35    /// Method E: Decomposition with shadow bookings (0.76% of entries)
36    /// Last resort - greedy allocation
37    /// Confidence = 1/decomposition_steps
38    MethodE = 4,
39
40    /// Not yet processed
41    Pending = 255,
42}
43
44impl SolvingMethod {
45    /// Expected percentage of entries using this method.
46    pub fn expected_ratio(&self) -> f64 {
47        match self {
48            SolvingMethod::MethodA => 0.6068,
49            SolvingMethod::MethodB => 0.1663,
50            SolvingMethod::MethodC => 0.11,
51            SolvingMethod::MethodD => 0.11,
52            SolvingMethod::MethodE => 0.0076,
53            SolvingMethod::Pending => 0.0,
54        }
55    }
56
57    /// Base confidence for this method.
58    pub fn base_confidence(&self) -> f32 {
59        match self {
60            SolvingMethod::MethodA => 1.0,
61            SolvingMethod::MethodB => 1.0, // May be reduced for duplicates
62            SolvingMethod::MethodC => 0.85,
63            SolvingMethod::MethodD => 1.0,
64            SolvingMethod::MethodE => 0.5, // Divided by steps
65            SolvingMethod::Pending => 0.0,
66        }
67    }
68
69    /// Display name for UI.
70    pub fn display_name(&self) -> &'static str {
71        match self {
72            SolvingMethod::MethodA => "A: 1-to-1",
73            SolvingMethod::MethodB => "B: n-to-n",
74            SolvingMethod::MethodC => "C: n-to-m",
75            SolvingMethod::MethodD => "D: Aggregate",
76            SolvingMethod::MethodE => "E: Decompose",
77            SolvingMethod::Pending => "Pending",
78        }
79    }
80
81    /// Color for visualization.
82    pub fn color(&self) -> [u8; 3] {
83        match self {
84            SolvingMethod::MethodA => [0, 200, 83],    // Green - best
85            SolvingMethod::MethodB => [100, 181, 246], // Blue
86            SolvingMethod::MethodC => [255, 193, 7],   // Amber
87            SolvingMethod::MethodD => [255, 152, 0],   // Orange
88            SolvingMethod::MethodE => [244, 67, 54],   // Red - worst
89            SolvingMethod::Pending => [158, 158, 158], // Gray
90        }
91    }
92}
93
94/// A journal entry header (ISO 21378:2019 compliant).
95/// GPU-aligned to 128 bytes.
96#[derive(Debug, Clone, Archive, Serialize, Deserialize)]
97#[repr(C, align(128))]
98pub struct JournalEntry {
99    // === Identity (32 bytes) ===
100    /// Unique entry identifier
101    pub id: Uuid,
102    /// Entity (company) ID
103    pub entity_id: Uuid,
104
105    // === Document reference (16 bytes) ===
106    /// Document number hash
107    pub document_number_hash: u64,
108    /// Source system identifier
109    pub source_system_id: u32,
110    /// Batch number for bulk imports
111    pub batch_id: u32,
112
113    // === Temporal (16 bytes) ===
114    /// When the entry was posted
115    pub posting_date: HybridTimestamp,
116
117    // === Line item counts (8 bytes) ===
118    /// Total line items
119    pub line_count: u16,
120    /// Number of debit lines
121    pub debit_line_count: u16,
122    /// Number of credit lines
123    pub credit_line_count: u16,
124    /// Index of first line in the line array
125    pub first_line_index: u16,
126
127    // === Amounts (32 bytes) ===
128    /// Sum of all debit amounts
129    pub total_debits: Decimal128,
130    /// Sum of all credit amounts (should equal total_debits)
131    pub total_credits: Decimal128,
132
133    // === Transformation (8 bytes) ===
134    /// Method used to transform to flows
135    pub solving_method: SolvingMethod,
136    /// Average confidence across generated flows
137    pub average_confidence: f32,
138    /// Number of flows generated
139    pub flow_count: u16,
140    /// Padding
141    pub _pad: u8,
142
143    // === Flags (4 bytes) ===
144    /// Entry property flags.
145    pub flags: JournalEntryFlags,
146
147    // === Reserved (12 bytes) ===
148    /// Reserved for future use.
149    pub _reserved: [u8; 12],
150}
151
152/// Bit flags for journal entry properties.
153#[derive(Debug, Clone, Copy, Default, Archive, Serialize, Deserialize)]
154#[repr(transparent)]
155pub struct JournalEntryFlags(pub u32);
156
157impl JournalEntryFlags {
158    /// Flag: Entry is balanced (debits = credits).
159    pub const IS_BALANCED: u32 = 1 << 0;
160    /// Flag: Entry has been transformed to flows.
161    pub const IS_TRANSFORMED: u32 = 1 << 1;
162    /// Flag: Contains decomposed/shadow values.
163    pub const HAS_DECOMPOSED_VALUES: u32 = 1 << 2;
164    /// Flag: Uses higher aggregate matching.
165    pub const USES_HIGHER_AGGREGATE: u32 = 1 << 3;
166    /// Flag: Flagged for audit review.
167    pub const FLAGGED_FOR_AUDIT: u32 = 1 << 4;
168    /// Flag: Reversing entry.
169    pub const IS_REVERSING: u32 = 1 << 5;
170    /// Flag: Recurring entry.
171    pub const IS_RECURRING: u32 = 1 << 6;
172    /// Flag: Adjustment entry.
173    pub const IS_ADJUSTMENT: u32 = 1 << 7;
174    /// Flag: Contains VAT lines.
175    pub const HAS_VAT: u32 = 1 << 8;
176    /// Flag: Intercompany transaction.
177    pub const IS_INTERCOMPANY: u32 = 1 << 9;
178
179    /// Create new flags (balanced by default).
180    pub fn new() -> Self {
181        Self(Self::IS_BALANCED) // Entries should be balanced by default
182    }
183
184    /// Check if entry is balanced.
185    pub fn is_balanced(&self) -> bool {
186        self.0 & Self::IS_BALANCED != 0
187    }
188    /// Check if entry has been transformed.
189    pub fn is_transformed(&self) -> bool {
190        self.0 & Self::IS_TRANSFORMED != 0
191    }
192    /// Check if entry is flagged for audit.
193    pub fn flagged_for_audit(&self) -> bool {
194        self.0 & Self::FLAGGED_FOR_AUDIT != 0
195    }
196}
197
198/// A single line item in a journal entry.
199/// GPU-aligned to 64 bytes.
200#[derive(Debug, Clone, Archive, Serialize, Deserialize)]
201#[repr(C, align(64))]
202pub struct JournalLineItem {
203    // === Identity (20 bytes) ===
204    /// Line item ID
205    pub id: Uuid,
206    /// Parent journal entry ID reference index
207    pub journal_entry_index: u32,
208
209    // === Account reference (8 bytes) ===
210    /// Account ID (references AccountNode)
211    pub account_index: u16,
212    /// Line number within entry (1-based)
213    pub line_number: u16,
214    /// Debit (0) or Credit (1)
215    pub line_type: LineType,
216    /// Padding
217    pub _pad1: [u8; 3],
218
219    // === Amount (16 bytes) ===
220    /// Monetary amount (positive for debit, negative for credit by convention)
221    pub amount: Decimal128,
222
223    // === Confidence and matching (8 bytes) ===
224    /// Confidence score (1.0 = original, <1.0 = estimated/decomposed)
225    pub confidence: f32,
226    /// Index of matched line (for Method A/B/C), u16::MAX if unmatched
227    pub matched_line_index: u16,
228    /// Flags
229    pub flags: LineItemFlags,
230    /// Padding
231    pub _pad2: u8,
232
233    // === Reserved (12 bytes) ===
234    /// Reserved for future use.
235    pub _reserved: [u8; 12],
236}
237
238/// Line type: Debit or Credit.
239#[derive(Debug, Clone, Copy, PartialEq, Eq, Archive, Serialize, Deserialize)]
240#[archive(compare(PartialEq))]
241#[repr(u8)]
242pub enum LineType {
243    /// Debit line (left side of entry).
244    Debit = 0,
245    /// Credit line (right side of entry).
246    Credit = 1,
247}
248
249impl LineType {
250    /// Check if this is a debit line.
251    pub fn is_debit(&self) -> bool {
252        matches!(self, LineType::Debit)
253    }
254    /// Check if this is a credit line.
255    pub fn is_credit(&self) -> bool {
256        matches!(self, LineType::Credit)
257    }
258}
259
260/// Bit flags for line item properties.
261#[derive(Debug, Clone, Copy, Default, Archive, Serialize, Deserialize)]
262#[repr(transparent)]
263pub struct LineItemFlags(pub u8);
264
265impl LineItemFlags {
266    /// Flag: Shadow booking (Method E decomposition).
267    pub const IS_SHADOW_BOOKING: u8 = 1 << 0;
268    /// Flag: Higher aggregate line (Method D).
269    pub const IS_HIGHER_AGGREGATE: u8 = 1 << 1;
270    /// Flag: VAT/tax line.
271    pub const IS_VAT_LINE: u8 = 1 << 2;
272    /// Flag: Rounding adjustment line.
273    pub const IS_ROUNDING_ADJUSTMENT: u8 = 1 << 3;
274    /// Flag: Line has been matched.
275    pub const IS_MATCHED: u8 = 1 << 4;
276}
277
278impl JournalEntry {
279    /// Create a new journal entry.
280    pub fn new(id: Uuid, entity_id: Uuid, posting_date: HybridTimestamp) -> Self {
281        Self {
282            id,
283            entity_id,
284            document_number_hash: 0,
285            source_system_id: 0,
286            batch_id: 0,
287            posting_date,
288            line_count: 0,
289            debit_line_count: 0,
290            credit_line_count: 0,
291            first_line_index: 0,
292            total_debits: Decimal128::ZERO,
293            total_credits: Decimal128::ZERO,
294            solving_method: SolvingMethod::Pending,
295            average_confidence: 0.0,
296            flow_count: 0,
297            _pad: 0,
298            flags: JournalEntryFlags::new(),
299            _reserved: [0; 12],
300        }
301    }
302
303    /// Check if the entry is balanced (debits = credits).
304    pub fn is_balanced(&self) -> bool {
305        (self.total_debits.to_f64() - self.total_credits.to_f64()).abs() < 0.01
306    }
307
308    /// Determine which solving method should be used.
309    pub fn determine_method(&self) -> SolvingMethod {
310        if self.debit_line_count == 1 && self.credit_line_count == 1 {
311            SolvingMethod::MethodA
312        } else if self.debit_line_count == self.credit_line_count {
313            SolvingMethod::MethodB
314        } else {
315            SolvingMethod::MethodC
316        }
317    }
318}
319
320impl JournalLineItem {
321    /// Create a new debit line.
322    pub fn debit(account_index: u16, amount: Decimal128, line_number: u16) -> Self {
323        Self {
324            id: Uuid::new_v4(),
325            journal_entry_index: 0,
326            account_index,
327            line_number,
328            line_type: LineType::Debit,
329            _pad1: [0; 3],
330            amount,
331            confidence: 1.0,
332            matched_line_index: u16::MAX,
333            flags: LineItemFlags(0),
334            _pad2: 0,
335            _reserved: [0; 12],
336        }
337    }
338
339    /// Create a new credit line.
340    pub fn credit(account_index: u16, amount: Decimal128, line_number: u16) -> Self {
341        Self {
342            id: Uuid::new_v4(),
343            journal_entry_index: 0,
344            account_index,
345            line_number,
346            line_type: LineType::Credit,
347            _pad1: [0; 3],
348            amount,
349            confidence: 1.0,
350            matched_line_index: u16::MAX,
351            flags: LineItemFlags(0),
352            _pad2: 0,
353            _reserved: [0; 12],
354        }
355    }
356
357    /// Check if this is a debit line.
358    pub fn is_debit(&self) -> bool {
359        self.line_type.is_debit()
360    }
361
362    /// Check if this is a credit line.
363    pub fn is_credit(&self) -> bool {
364        self.line_type.is_credit()
365    }
366
367    /// Check if this line has been matched to another.
368    pub fn is_matched(&self) -> bool {
369        self.matched_line_index != u16::MAX
370    }
371}
372
373/// Common booking patterns for pattern recognition and confidence boosting.
374#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
375#[repr(u8)]
376pub enum BookingPatternType {
377    /// Cash receipt from customer (DR Cash, CR A/R or Revenue)
378    CashReceipt = 0,
379    /// Cash payment to vendor (DR A/P or Expense, CR Cash)
380    CashPayment = 1,
381    /// Sales transaction (DR A/R, CR Revenue, possibly CR VAT)
382    SalesRevenue = 2,
383    /// Purchase transaction (DR Expense/Inventory, CR A/P)
384    Purchase = 3,
385    /// Payroll entry (DR Salary Expense, CR Cash/Payroll Payable)
386    Payroll = 4,
387    /// Depreciation (DR Depreciation Expense, CR Accumulated Depreciation)
388    Depreciation = 5,
389    /// Accrual entry (DR Expense, CR Accrued Liability)
390    Accrual = 6,
391    /// Reversal entry (opposite of original)
392    Reversal = 7,
393    /// Intercompany transfer
394    Intercompany = 8,
395    /// VAT settlement
396    VatSettlement = 9,
397    /// Bank reconciliation
398    BankReconciliation = 10,
399    /// Unknown/other pattern
400    Unknown = 255,
401}
402
403impl BookingPatternType {
404    /// Expected account type for debit side.
405    pub fn expected_debit_type(&self) -> Option<AccountType> {
406        match self {
407            BookingPatternType::CashReceipt => Some(AccountType::Asset), // Cash
408            BookingPatternType::CashPayment => Some(AccountType::Liability), // A/P
409            BookingPatternType::SalesRevenue => Some(AccountType::Asset), // A/R
410            BookingPatternType::Purchase => Some(AccountType::Expense),
411            BookingPatternType::Payroll => Some(AccountType::Expense),
412            BookingPatternType::Depreciation => Some(AccountType::Expense),
413            BookingPatternType::Accrual => Some(AccountType::Expense),
414            _ => None,
415        }
416    }
417
418    /// Expected account type for credit side.
419    pub fn expected_credit_type(&self) -> Option<AccountType> {
420        match self {
421            BookingPatternType::CashReceipt => Some(AccountType::Revenue),
422            BookingPatternType::CashPayment => Some(AccountType::Asset), // Cash
423            BookingPatternType::SalesRevenue => Some(AccountType::Revenue),
424            BookingPatternType::Purchase => Some(AccountType::Liability), // A/P
425            BookingPatternType::Payroll => Some(AccountType::Asset),      // Cash
426            BookingPatternType::Depreciation => Some(AccountType::Contra), // Accum Depr
427            BookingPatternType::Accrual => Some(AccountType::Liability),
428            _ => None,
429        }
430    }
431
432    /// Confidence boost when pattern is matched.
433    pub fn confidence_boost(&self) -> f32 {
434        match self {
435            BookingPatternType::CashReceipt => 0.20,
436            BookingPatternType::CashPayment => 0.20,
437            BookingPatternType::SalesRevenue => 0.15,
438            BookingPatternType::Purchase => 0.15,
439            BookingPatternType::Payroll => 0.25,
440            BookingPatternType::Depreciation => 0.25,
441            _ => 0.10,
442        }
443    }
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449
450    #[test]
451    fn test_journal_entry_size() {
452        let size = std::mem::size_of::<JournalEntry>();
453        assert!(
454            size >= 128,
455            "JournalEntry should be at least 128 bytes, got {}",
456            size
457        );
458        assert!(
459            size.is_multiple_of(128),
460            "JournalEntry should be 128-byte aligned, got {}",
461            size
462        );
463    }
464
465    #[test]
466    fn test_line_item_size() {
467        let size = std::mem::size_of::<JournalLineItem>();
468        assert!(
469            size >= 64,
470            "JournalLineItem should be at least 64 bytes, got {}",
471            size
472        );
473        assert!(
474            size.is_multiple_of(64),
475            "JournalLineItem should be 64-byte aligned, got {}",
476            size
477        );
478    }
479
480    #[test]
481    fn test_method_determination() {
482        let mut entry = JournalEntry::new(Uuid::new_v4(), Uuid::new_v4(), HybridTimestamp::now());
483
484        // 1 debit, 1 credit -> Method A
485        entry.debit_line_count = 1;
486        entry.credit_line_count = 1;
487        assert_eq!(entry.determine_method(), SolvingMethod::MethodA);
488
489        // 3 debits, 3 credits -> Method B
490        entry.debit_line_count = 3;
491        entry.credit_line_count = 3;
492        assert_eq!(entry.determine_method(), SolvingMethod::MethodB);
493
494        // 2 debits, 5 credits -> Method C
495        entry.debit_line_count = 2;
496        entry.credit_line_count = 5;
497        assert_eq!(entry.determine_method(), SolvingMethod::MethodC);
498    }
499}