1use 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#[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 #[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 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 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 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 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 fn validate_entry(entry: &JournalEntry, config: &TransformConfig) -> Vec<ValidationError> {
108 let mut errors = Vec::new();
109
110 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 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 for line in &entry.lines {
140 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 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 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 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 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 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 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 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 fn get_period_key(timestamp: u64, period_type: PeriodType) -> String {
293 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#[derive(Debug, Clone)]
313pub struct TransformConfig {
314 pub skip_invalid: bool,
316 pub preserve_unmapped: bool,
318 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#[derive(Debug, Clone)]
334pub struct AccountSummary {
335 pub account_code: String,
337 pub total_debit: f64,
339 pub total_credit: f64,
341 pub line_count: usize,
343 pub entry_count: usize,
345}
346
347#[derive(Debug, Clone, Copy)]
349pub enum PeriodType {
350 Daily,
352 Weekly,
354 Monthly,
356 Quarterly,
358 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; 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 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 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; let entries = vec![entry1, entry2];
584 let groups = JournalTransformation::group_by_period(&entries, PeriodType::Monthly);
585
586 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; 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}