rustkernel_accounting/
journal.rs

1//! Journal transformation kernel.
2//!
3//! This module provides journal transformation for accounting:
4//! - Transform journal entries between formats
5//! - Validate entries
6//! - Apply GL mappings
7
8use crate::types::{
9    ErrorSeverity, JournalEntry, JournalLine, MappedAccount, MappingResult, TransformationResult,
10    TransformationStats, ValidationError,
11};
12use rustkernel_core::{domain::Domain, kernel::KernelMetadata, traits::GpuKernel};
13use std::collections::HashMap;
14
15// ============================================================================
16// Journal Transformation Kernel
17// ============================================================================
18
19/// Journal transformation kernel.
20///
21/// Transforms and validates journal entries.
22#[derive(Debug, Clone)]
23pub struct JournalTransformation {
24    metadata: KernelMetadata,
25}
26
27impl Default for JournalTransformation {
28    fn default() -> Self {
29        Self::new()
30    }
31}
32
33impl JournalTransformation {
34    /// Create a new journal transformation kernel.
35    #[must_use]
36    pub fn new() -> Self {
37        Self {
38            metadata: KernelMetadata::batch("accounting/journal-transform", Domain::Accounting)
39                .with_description("Journal entry transformation and GL mapping")
40                .with_throughput(30_000)
41                .with_latency_us(100.0),
42        }
43    }
44
45    /// Transform journal entries using account mappings.
46    pub fn transform(
47        entries: &[JournalEntry],
48        mapping: &MappingResult,
49        config: &TransformConfig,
50    ) -> TransformationResult {
51        let mut transformed_entries = Vec::new();
52        let mut errors = Vec::new();
53        let mut total_debit = 0.0;
54        let mut total_credit = 0.0;
55
56        // Build mapping lookup
57        let mapping_lookup: HashMap<String, Vec<&MappedAccount>> =
58            mapping.mapped.iter().fold(HashMap::new(), |mut acc, m| {
59                acc.entry(m.source_code.clone()).or_default().push(m);
60                acc
61            });
62
63        for entry in entries {
64            // Validate before transformation
65            let entry_errors = Self::validate_entry(entry, config);
66            if !entry_errors.is_empty() {
67                if config.skip_invalid {
68                    errors.extend(entry_errors);
69                    continue;
70                } else {
71                    errors.extend(entry_errors);
72                }
73            }
74
75            // Transform the entry
76            match Self::transform_entry(entry, &mapping_lookup, config) {
77                Ok(transformed) => {
78                    for line in &transformed.lines {
79                        total_debit += line.debit;
80                        total_credit += line.credit;
81                    }
82                    transformed_entries.push(transformed);
83                }
84                Err(e) => {
85                    errors.push(e);
86                }
87            }
88        }
89
90        let transformed_count = transformed_entries.len();
91        let error_count = errors.len();
92
93        TransformationResult {
94            entries: transformed_entries,
95            errors,
96            stats: TransformationStats {
97                total_entries: entries.len(),
98                transformed_count,
99                error_count,
100                total_debit,
101                total_credit,
102            },
103        }
104    }
105
106    /// Validate a journal entry.
107    fn validate_entry(entry: &JournalEntry, config: &TransformConfig) -> Vec<ValidationError> {
108        let mut errors = Vec::new();
109
110        // Check for empty lines
111        if entry.lines.is_empty() {
112            errors.push(ValidationError {
113                entry_id: entry.id,
114                line_number: None,
115                code: "EMPTY_ENTRY".to_string(),
116                message: "Journal entry has no lines".to_string(),
117                severity: ErrorSeverity::Error,
118            });
119        }
120
121        // Check debit/credit balance
122        let total_debit: f64 = entry.lines.iter().map(|l| l.debit).sum();
123        let total_credit: f64 = entry.lines.iter().map(|l| l.credit).sum();
124
125        if (total_debit - total_credit).abs() > config.balance_tolerance {
126            errors.push(ValidationError {
127                entry_id: entry.id,
128                line_number: None,
129                code: "UNBALANCED".to_string(),
130                message: format!(
131                    "Entry is unbalanced: debit={}, credit={}",
132                    total_debit, total_credit
133                ),
134                severity: ErrorSeverity::Error,
135            });
136        }
137
138        // Validate individual lines
139        for line in &entry.lines {
140            // Check for both debit and credit on same line
141            if line.debit > 0.0 && line.credit > 0.0 {
142                errors.push(ValidationError {
143                    entry_id: entry.id,
144                    line_number: Some(line.line_number),
145                    code: "DUAL_SIDED".to_string(),
146                    message: "Line has both debit and credit".to_string(),
147                    severity: ErrorSeverity::Warning,
148                });
149            }
150
151            // Check for zero amounts
152            if line.debit == 0.0 && line.credit == 0.0 {
153                errors.push(ValidationError {
154                    entry_id: entry.id,
155                    line_number: Some(line.line_number),
156                    code: "ZERO_AMOUNT".to_string(),
157                    message: "Line has zero amount".to_string(),
158                    severity: ErrorSeverity::Warning,
159                });
160            }
161
162            // Check for empty account code
163            if line.account_code.is_empty() {
164                errors.push(ValidationError {
165                    entry_id: entry.id,
166                    line_number: Some(line.line_number),
167                    code: "EMPTY_ACCOUNT".to_string(),
168                    message: "Line has empty account code".to_string(),
169                    severity: ErrorSeverity::Error,
170                });
171            }
172        }
173
174        errors
175    }
176
177    /// Transform a single entry.
178    fn transform_entry(
179        entry: &JournalEntry,
180        mapping_lookup: &HashMap<String, Vec<&MappedAccount>>,
181        config: &TransformConfig,
182    ) -> Result<JournalEntry, ValidationError> {
183        let mut new_lines = Vec::new();
184        let mut line_number = 1u32;
185
186        for line in &entry.lines {
187            let mappings = mapping_lookup.get(&line.account_code);
188
189            match mappings {
190                Some(mapped_accounts) => {
191                    for mapped in mapped_accounts {
192                        let new_line = JournalLine {
193                            line_number,
194                            account_code: mapped.target_code.clone(),
195                            debit: line.debit * mapped.amount_ratio,
196                            credit: line.credit * mapped.amount_ratio,
197                            currency: line.currency.clone(),
198                            entity_id: line.entity_id.clone(),
199                            cost_center: line.cost_center.clone(),
200                            description: line.description.clone(),
201                        };
202                        new_lines.push(new_line);
203                        line_number += 1;
204                    }
205                }
206                None => {
207                    if config.preserve_unmapped {
208                        // Keep original line
209                        let mut preserved = line.clone();
210                        preserved.line_number = line_number;
211                        new_lines.push(preserved);
212                        line_number += 1;
213                    } else {
214                        return Err(ValidationError {
215                            entry_id: entry.id,
216                            line_number: Some(line.line_number),
217                            code: "UNMAPPED_ACCOUNT".to_string(),
218                            message: format!("No mapping for account {}", line.account_code),
219                            severity: ErrorSeverity::Error,
220                        });
221                    }
222                }
223            }
224        }
225
226        Ok(JournalEntry {
227            id: entry.id,
228            date: entry.date,
229            posting_date: entry.posting_date,
230            document_number: entry.document_number.clone(),
231            lines: new_lines,
232            status: entry.status,
233            source_system: entry.source_system.clone(),
234            description: entry.description.clone(),
235        })
236    }
237
238    /// Aggregate entries by account.
239    pub fn aggregate_by_account(entries: &[JournalEntry]) -> HashMap<String, AccountSummary> {
240        let mut summaries: HashMap<String, AccountSummary> = HashMap::new();
241
242        for entry in entries {
243            for line in &entry.lines {
244                let summary = summaries
245                    .entry(line.account_code.clone())
246                    .or_insert_with(|| AccountSummary {
247                        account_code: line.account_code.clone(),
248                        total_debit: 0.0,
249                        total_credit: 0.0,
250                        line_count: 0,
251                        entry_count: 0,
252                    });
253
254                summary.total_debit += line.debit;
255                summary.total_credit += line.credit;
256                summary.line_count += 1;
257            }
258        }
259
260        // Count distinct entries per account
261        for entry in entries {
262            let mut seen_accounts: std::collections::HashSet<&str> =
263                std::collections::HashSet::new();
264            for line in &entry.lines {
265                if seen_accounts.insert(&line.account_code) {
266                    if let Some(summary) = summaries.get_mut(&line.account_code) {
267                        summary.entry_count += 1;
268                    }
269                }
270            }
271        }
272
273        summaries
274    }
275
276    /// Group entries by period.
277    pub fn group_by_period(
278        entries: &[JournalEntry],
279        period_type: PeriodType,
280    ) -> HashMap<String, Vec<&JournalEntry>> {
281        let mut groups: HashMap<String, Vec<&JournalEntry>> = HashMap::new();
282
283        for entry in entries {
284            let period_key = Self::get_period_key(entry.posting_date, period_type);
285            groups.entry(period_key).or_default().push(entry);
286        }
287
288        groups
289    }
290
291    /// Get period key for a date.
292    fn get_period_key(timestamp: u64, period_type: PeriodType) -> String {
293        // Simplified period calculation
294        let days = timestamp / 86400;
295        match period_type {
296            PeriodType::Daily => format!("D{}", days),
297            PeriodType::Weekly => format!("W{}", days / 7),
298            PeriodType::Monthly => format!("M{}", days / 30),
299            PeriodType::Quarterly => format!("Q{}", days / 90),
300            PeriodType::Yearly => format!("Y{}", days / 365),
301        }
302    }
303}
304
305impl GpuKernel for JournalTransformation {
306    fn metadata(&self) -> &KernelMetadata {
307        &self.metadata
308    }
309}
310
311/// Transformation configuration.
312#[derive(Debug, Clone)]
313pub struct TransformConfig {
314    /// Skip invalid entries.
315    pub skip_invalid: bool,
316    /// Preserve unmapped accounts.
317    pub preserve_unmapped: bool,
318    /// Balance tolerance.
319    pub balance_tolerance: f64,
320}
321
322impl Default for TransformConfig {
323    fn default() -> Self {
324        Self {
325            skip_invalid: false,
326            preserve_unmapped: true,
327            balance_tolerance: 0.01,
328        }
329    }
330}
331
332/// Account summary.
333#[derive(Debug, Clone)]
334pub struct AccountSummary {
335    /// Account code.
336    pub account_code: String,
337    /// Total debit.
338    pub total_debit: f64,
339    /// Total credit.
340    pub total_credit: f64,
341    /// Line count.
342    pub line_count: usize,
343    /// Entry count.
344    pub entry_count: usize,
345}
346
347/// Period type.
348#[derive(Debug, Clone, Copy)]
349pub enum PeriodType {
350    /// Daily.
351    Daily,
352    /// Weekly.
353    Weekly,
354    /// Monthly.
355    Monthly,
356    /// Quarterly.
357    Quarterly,
358    /// Yearly.
359    Yearly,
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365    use crate::types::{JournalStatus, MappingStats};
366
367    fn create_test_entry() -> JournalEntry {
368        JournalEntry {
369            id: 1,
370            date: 1700000000,
371            posting_date: 1700000000,
372            document_number: "JE001".to_string(),
373            lines: vec![
374                JournalLine {
375                    line_number: 1,
376                    account_code: "1000".to_string(),
377                    debit: 1000.0,
378                    credit: 0.0,
379                    currency: "USD".to_string(),
380                    entity_id: "CORP".to_string(),
381                    cost_center: None,
382                    description: "Cash debit".to_string(),
383                },
384                JournalLine {
385                    line_number: 2,
386                    account_code: "4000".to_string(),
387                    debit: 0.0,
388                    credit: 1000.0,
389                    currency: "USD".to_string(),
390                    entity_id: "CORP".to_string(),
391                    cost_center: None,
392                    description: "Revenue credit".to_string(),
393                },
394            ],
395            status: JournalStatus::Draft,
396            source_system: "TEST".to_string(),
397            description: "Test entry".to_string(),
398        }
399    }
400
401    fn create_test_mapping() -> MappingResult {
402        MappingResult {
403            mapped: vec![
404                MappedAccount {
405                    source_code: "1000".to_string(),
406                    target_code: "A1000".to_string(),
407                    rule_id: "R1".to_string(),
408                    amount_ratio: 1.0,
409                },
410                MappedAccount {
411                    source_code: "4000".to_string(),
412                    target_code: "R4000".to_string(),
413                    rule_id: "R2".to_string(),
414                    amount_ratio: 1.0,
415                },
416            ],
417            unmapped: vec![],
418            stats: MappingStats {
419                total_accounts: 2,
420                mapped_count: 2,
421                unmapped_count: 0,
422                rules_applied: 2,
423                mapping_rate: 1.0,
424            },
425        }
426    }
427
428    #[test]
429    fn test_journal_metadata() {
430        let kernel = JournalTransformation::new();
431        assert_eq!(kernel.metadata().id, "accounting/journal-transform");
432        assert_eq!(kernel.metadata().domain, Domain::Accounting);
433    }
434
435    #[test]
436    fn test_basic_transformation() {
437        let entries = vec![create_test_entry()];
438        let mapping = create_test_mapping();
439        let config = TransformConfig::default();
440
441        let result = JournalTransformation::transform(&entries, &mapping, &config);
442
443        assert_eq!(result.stats.transformed_count, 1);
444        assert!(result.errors.is_empty());
445        assert_eq!(result.entries[0].lines[0].account_code, "A1000");
446        assert_eq!(result.entries[0].lines[1].account_code, "R4000");
447    }
448
449    #[test]
450    fn test_unbalanced_entry() {
451        let mut entry = create_test_entry();
452        entry.lines[0].debit = 1500.0; // Make unbalanced
453
454        let errors = JournalTransformation::validate_entry(&entry, &TransformConfig::default());
455
456        assert!(errors.iter().any(|e| e.code == "UNBALANCED"));
457    }
458
459    #[test]
460    fn test_empty_entry() {
461        let entry = JournalEntry {
462            id: 1,
463            date: 1700000000,
464            posting_date: 1700000000,
465            document_number: "JE001".to_string(),
466            lines: vec![],
467            status: JournalStatus::Draft,
468            source_system: "TEST".to_string(),
469            description: "Empty".to_string(),
470        };
471
472        let errors = JournalTransformation::validate_entry(&entry, &TransformConfig::default());
473
474        assert!(errors.iter().any(|e| e.code == "EMPTY_ENTRY"));
475    }
476
477    #[test]
478    fn test_preserve_unmapped() {
479        let entries = vec![create_test_entry()];
480        let mapping = MappingResult {
481            mapped: vec![MappedAccount {
482                source_code: "1000".to_string(),
483                target_code: "A1000".to_string(),
484                rule_id: "R1".to_string(),
485                amount_ratio: 1.0,
486            }],
487            unmapped: vec!["4000".to_string()],
488            stats: MappingStats {
489                total_accounts: 2,
490                mapped_count: 1,
491                unmapped_count: 1,
492                rules_applied: 1,
493                mapping_rate: 0.5,
494            },
495        };
496
497        let config = TransformConfig {
498            preserve_unmapped: true,
499            ..Default::default()
500        };
501
502        let result = JournalTransformation::transform(&entries, &mapping, &config);
503
504        // Should preserve unmapped 4000 account
505        assert_eq!(result.stats.transformed_count, 1);
506        assert!(
507            result.entries[0]
508                .lines
509                .iter()
510                .any(|l| l.account_code == "4000")
511        );
512    }
513
514    #[test]
515    fn test_split_transformation() {
516        let entries = vec![create_test_entry()];
517        let mapping = MappingResult {
518            mapped: vec![
519                MappedAccount {
520                    source_code: "1000".to_string(),
521                    target_code: "A1001".to_string(),
522                    rule_id: "R1".to_string(),
523                    amount_ratio: 0.6,
524                },
525                MappedAccount {
526                    source_code: "1000".to_string(),
527                    target_code: "A1002".to_string(),
528                    rule_id: "R1".to_string(),
529                    amount_ratio: 0.4,
530                },
531                MappedAccount {
532                    source_code: "4000".to_string(),
533                    target_code: "R4000".to_string(),
534                    rule_id: "R2".to_string(),
535                    amount_ratio: 1.0,
536                },
537            ],
538            unmapped: vec![],
539            stats: MappingStats {
540                total_accounts: 2,
541                mapped_count: 2,
542                unmapped_count: 0,
543                rules_applied: 2,
544                mapping_rate: 1.0,
545            },
546        };
547
548        let result =
549            JournalTransformation::transform(&entries, &mapping, &TransformConfig::default());
550
551        // Should have 3 lines (1000 split to 2, 4000 to 1)
552        assert_eq!(result.entries[0].lines.len(), 3);
553
554        let a1001_line = result.entries[0]
555            .lines
556            .iter()
557            .find(|l| l.account_code == "A1001")
558            .unwrap();
559        assert!((a1001_line.debit - 600.0).abs() < 0.01);
560    }
561
562    #[test]
563    fn test_aggregate_by_account() {
564        let entries = vec![create_test_entry(), create_test_entry()];
565
566        let summaries = JournalTransformation::aggregate_by_account(&entries);
567
568        let cash_summary = summaries.get("1000").unwrap();
569        assert_eq!(cash_summary.total_debit, 2000.0);
570        assert_eq!(cash_summary.line_count, 2);
571        assert_eq!(cash_summary.entry_count, 2);
572    }
573
574    #[test]
575    fn test_group_by_period() {
576        let mut entry1 = create_test_entry();
577        entry1.posting_date = 1700000000;
578
579        let mut entry2 = create_test_entry();
580        entry2.id = 2;
581        entry2.posting_date = 1700000000 + 86400 * 35; // 35 days later
582
583        let entries = vec![entry1, entry2];
584        let groups = JournalTransformation::group_by_period(&entries, PeriodType::Monthly);
585
586        // Should be in different monthly periods
587        assert_eq!(groups.len(), 2);
588    }
589
590    #[test]
591    fn test_skip_invalid() {
592        let mut entry = create_test_entry();
593        entry.lines[0].debit = 2000.0; // Unbalanced
594
595        let entries = vec![entry];
596        let mapping = create_test_mapping();
597
598        let config = TransformConfig {
599            skip_invalid: true,
600            ..Default::default()
601        };
602
603        let result = JournalTransformation::transform(&entries, &mapping, &config);
604
605        assert_eq!(result.stats.transformed_count, 0);
606        assert!(!result.errors.is_empty());
607    }
608}