1use chrono::NaiveDate;
4use rust_decimal::Decimal;
5use rust_decimal_macros::dec;
6use std::collections::HashMap;
7
8use datasynth_core::accounts::control_accounts;
9use datasynth_core::models::subledger::ap::APInvoice;
10use datasynth_core::models::subledger::ar::ARInvoice;
11use datasynth_core::models::subledger::fa::FixedAssetRecord;
12use datasynth_core::models::subledger::inventory::InventoryPosition;
13use datasynth_core::models::subledger::SubledgerType;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum ReconStatus {
18 Reconciled,
20 PartiallyReconciled,
22 Unreconciled,
24 InProgress,
26}
27
28#[derive(Debug, Clone)]
30pub struct UnreconciledEntry {
31 pub entry_type: String,
33 pub document_number: String,
35 pub amount: Decimal,
37 pub description: String,
39}
40
41#[derive(Debug, Clone)]
43pub struct ReconciliationResult {
44 pub reconciliation_id: String,
46 pub company_code: String,
48 pub subledger_type: SubledgerType,
50 pub as_of_date: NaiveDate,
52 pub gl_account: String,
54 pub gl_balance: Decimal,
56 pub subledger_balance: Decimal,
58 pub difference: Decimal,
60 pub status: ReconStatus,
62 pub unreconciled_items: Vec<UnreconciledEntry>,
64 pub reconciliation_date: NaiveDate,
66 pub reconciled_by: Option<String>,
68 pub notes: Option<String>,
70}
71
72impl ReconciliationResult {
73 pub fn is_balanced(&self) -> bool {
75 self.difference.abs() < dec!(0.01)
76 }
77}
78
79#[derive(Debug, Clone)]
81pub struct ReconciliationConfig {
82 pub tolerance_amount: Decimal,
84 pub ar_control_account: String,
86 pub ap_control_account: String,
88 pub fa_control_account: String,
90 pub inventory_control_account: String,
92}
93
94impl Default for ReconciliationConfig {
95 fn default() -> Self {
96 Self {
97 tolerance_amount: dec!(0.01),
98 ar_control_account: control_accounts::AR_CONTROL.to_string(),
99 ap_control_account: control_accounts::AP_CONTROL.to_string(),
100 fa_control_account: control_accounts::FIXED_ASSETS.to_string(),
101 inventory_control_account: control_accounts::INVENTORY.to_string(),
102 }
103 }
104}
105
106pub struct ReconciliationEngine {
108 config: ReconciliationConfig,
109 reconciliation_counter: u64,
110}
111
112impl ReconciliationEngine {
113 pub fn new(config: ReconciliationConfig) -> Self {
115 Self {
116 config,
117 reconciliation_counter: 0,
118 }
119 }
120
121 pub fn reconcile_ar(
123 &mut self,
124 company_code: &str,
125 as_of_date: NaiveDate,
126 gl_balance: Decimal,
127 ar_invoices: &[&ARInvoice],
128 ) -> ReconciliationResult {
129 self.reconciliation_counter += 1;
130 let reconciliation_id = format!("RECON-AR-{:08}", self.reconciliation_counter);
131
132 let subledger_balance: Decimal = ar_invoices.iter().map(|inv| inv.amount_remaining).sum();
133
134 let difference = gl_balance - subledger_balance;
135
136 let mut unreconciled_items = Vec::new();
137
138 if difference.abs() >= self.config.tolerance_amount {
140 for invoice in ar_invoices {
142 if invoice.posting_date > as_of_date {
143 unreconciled_items.push(UnreconciledEntry {
144 entry_type: "Timing Difference".to_string(),
145 document_number: invoice.invoice_number.clone(),
146 amount: invoice.amount_remaining,
147 description: format!(
148 "Invoice posted after reconciliation date: {}",
149 invoice.posting_date
150 ),
151 });
152 }
153 }
154 }
155
156 let status = if difference.abs() < self.config.tolerance_amount {
157 ReconStatus::Reconciled
158 } else if !unreconciled_items.is_empty() {
159 ReconStatus::PartiallyReconciled
160 } else {
161 ReconStatus::Unreconciled
162 };
163
164 ReconciliationResult {
165 reconciliation_id,
166 company_code: company_code.to_string(),
167 subledger_type: SubledgerType::AR,
168 as_of_date,
169 gl_account: self.config.ar_control_account.clone(),
170 gl_balance,
171 subledger_balance,
172 difference,
173 status,
174 unreconciled_items,
175 reconciliation_date: as_of_date,
176 reconciled_by: None,
177 notes: None,
178 }
179 }
180
181 pub fn reconcile_ap(
183 &mut self,
184 company_code: &str,
185 as_of_date: NaiveDate,
186 gl_balance: Decimal,
187 ap_invoices: &[&APInvoice],
188 ) -> ReconciliationResult {
189 self.reconciliation_counter += 1;
190 let reconciliation_id = format!("RECON-AP-{:08}", self.reconciliation_counter);
191
192 let subledger_balance: Decimal = ap_invoices.iter().map(|inv| inv.amount_remaining).sum();
193
194 let difference = gl_balance - subledger_balance;
195
196 let mut unreconciled_items = Vec::new();
197
198 if difference.abs() >= self.config.tolerance_amount {
199 for invoice in ap_invoices {
200 if invoice.posting_date > as_of_date {
201 unreconciled_items.push(UnreconciledEntry {
202 entry_type: "Timing Difference".to_string(),
203 document_number: invoice.invoice_number.clone(),
204 amount: invoice.amount_remaining,
205 description: format!(
206 "Invoice posted after reconciliation date: {}",
207 invoice.posting_date
208 ),
209 });
210 }
211 }
212 }
213
214 let status = if difference.abs() < self.config.tolerance_amount {
215 ReconStatus::Reconciled
216 } else if !unreconciled_items.is_empty() {
217 ReconStatus::PartiallyReconciled
218 } else {
219 ReconStatus::Unreconciled
220 };
221
222 ReconciliationResult {
223 reconciliation_id,
224 company_code: company_code.to_string(),
225 subledger_type: SubledgerType::AP,
226 as_of_date,
227 gl_account: self.config.ap_control_account.clone(),
228 gl_balance,
229 subledger_balance,
230 difference,
231 status,
232 unreconciled_items,
233 reconciliation_date: as_of_date,
234 reconciled_by: None,
235 notes: None,
236 }
237 }
238
239 pub fn reconcile_fa(
241 &mut self,
242 company_code: &str,
243 as_of_date: NaiveDate,
244 gl_asset_balance: Decimal,
245 gl_accum_depr_balance: Decimal,
246 assets: &[&FixedAssetRecord],
247 ) -> (ReconciliationResult, ReconciliationResult) {
248 self.reconciliation_counter += 1;
250 let asset_recon_id = format!("RECON-FA-{:08}", self.reconciliation_counter);
251
252 let subledger_asset_balance: Decimal =
253 assets.iter().map(|a| a.current_acquisition_cost()).sum();
254
255 let asset_difference = gl_asset_balance - subledger_asset_balance;
256
257 let asset_status = if asset_difference.abs() < self.config.tolerance_amount {
258 ReconStatus::Reconciled
259 } else {
260 ReconStatus::Unreconciled
261 };
262
263 let asset_result = ReconciliationResult {
264 reconciliation_id: asset_recon_id,
265 company_code: company_code.to_string(),
266 subledger_type: SubledgerType::FA,
267 as_of_date,
268 gl_account: self.config.fa_control_account.clone(),
269 gl_balance: gl_asset_balance,
270 subledger_balance: subledger_asset_balance,
271 difference: asset_difference,
272 status: asset_status,
273 unreconciled_items: Vec::new(),
274 reconciliation_date: as_of_date,
275 reconciled_by: None,
276 notes: Some("Fixed Asset - Acquisition Cost".to_string()),
277 };
278
279 self.reconciliation_counter += 1;
281 let depr_recon_id = format!("RECON-FA-{:08}", self.reconciliation_counter);
282
283 let subledger_accum_depr: Decimal = assets.iter().map(|a| a.accumulated_depreciation).sum();
284
285 let depr_difference = gl_accum_depr_balance - subledger_accum_depr;
286
287 let depr_status = if depr_difference.abs() < self.config.tolerance_amount {
288 ReconStatus::Reconciled
289 } else {
290 ReconStatus::Unreconciled
291 };
292
293 let depr_result = ReconciliationResult {
294 reconciliation_id: depr_recon_id,
295 company_code: company_code.to_string(),
296 subledger_type: SubledgerType::FA,
297 as_of_date,
298 gl_account: format!("{}-ACCUM", self.config.fa_control_account),
299 gl_balance: gl_accum_depr_balance,
300 subledger_balance: subledger_accum_depr,
301 difference: depr_difference,
302 status: depr_status,
303 unreconciled_items: Vec::new(),
304 reconciliation_date: as_of_date,
305 reconciled_by: None,
306 notes: Some("Fixed Asset - Accumulated Depreciation".to_string()),
307 };
308
309 (asset_result, depr_result)
310 }
311
312 pub fn reconcile_inventory(
314 &mut self,
315 company_code: &str,
316 as_of_date: NaiveDate,
317 gl_balance: Decimal,
318 positions: &[&InventoryPosition],
319 ) -> ReconciliationResult {
320 self.reconciliation_counter += 1;
321 let reconciliation_id = format!("RECON-INV-{:08}", self.reconciliation_counter);
322
323 let subledger_balance: Decimal = positions.iter().map(|p| p.valuation.total_value).sum();
324
325 let difference = gl_balance - subledger_balance;
326
327 let mut unreconciled_items = Vec::new();
328
329 if difference.abs() >= self.config.tolerance_amount {
330 for position in positions {
332 if position.quantity_on_hand > Decimal::ZERO
333 && position.valuation.total_value == Decimal::ZERO
334 {
335 unreconciled_items.push(UnreconciledEntry {
336 entry_type: "Valuation Issue".to_string(),
337 document_number: position.material_id.clone(),
338 amount: Decimal::ZERO,
339 description: format!(
340 "Material {} has quantity {} but zero value",
341 position.material_id, position.quantity_on_hand
342 ),
343 });
344 }
345 }
346 }
347
348 let status = if difference.abs() < self.config.tolerance_amount {
349 ReconStatus::Reconciled
350 } else if !unreconciled_items.is_empty() {
351 ReconStatus::PartiallyReconciled
352 } else {
353 ReconStatus::Unreconciled
354 };
355
356 ReconciliationResult {
357 reconciliation_id,
358 company_code: company_code.to_string(),
359 subledger_type: SubledgerType::Inventory,
360 as_of_date,
361 gl_account: self.config.inventory_control_account.clone(),
362 gl_balance,
363 subledger_balance,
364 difference,
365 status,
366 unreconciled_items,
367 reconciliation_date: as_of_date,
368 reconciled_by: None,
369 notes: None,
370 }
371 }
372
373 pub fn full_reconciliation(
375 &mut self,
376 company_code: &str,
377 as_of_date: NaiveDate,
378 gl_balances: &HashMap<String, Decimal>,
379 ar_invoices: &[&ARInvoice],
380 ap_invoices: &[&APInvoice],
381 assets: &[&FixedAssetRecord],
382 inventory_positions: &[&InventoryPosition],
383 ) -> FullReconciliationReport {
384 let ar_result = self.reconcile_ar(
385 company_code,
386 as_of_date,
387 *gl_balances
388 .get(&self.config.ar_control_account)
389 .unwrap_or(&Decimal::ZERO),
390 ar_invoices,
391 );
392
393 let ap_result = self.reconcile_ap(
394 company_code,
395 as_of_date,
396 *gl_balances
397 .get(&self.config.ap_control_account)
398 .unwrap_or(&Decimal::ZERO),
399 ap_invoices,
400 );
401
402 let fa_asset_balance = *gl_balances
403 .get(&self.config.fa_control_account)
404 .unwrap_or(&Decimal::ZERO);
405 let fa_depr_balance = *gl_balances
406 .get(&format!("{}-ACCUM", self.config.fa_control_account))
407 .unwrap_or(&Decimal::ZERO);
408
409 let (fa_asset_result, fa_depr_result) = self.reconcile_fa(
410 company_code,
411 as_of_date,
412 fa_asset_balance,
413 fa_depr_balance,
414 assets,
415 );
416
417 let inventory_result = self.reconcile_inventory(
418 company_code,
419 as_of_date,
420 *gl_balances
421 .get(&self.config.inventory_control_account)
422 .unwrap_or(&Decimal::ZERO),
423 inventory_positions,
424 );
425
426 let all_reconciled = ar_result.is_balanced()
427 && ap_result.is_balanced()
428 && fa_asset_result.is_balanced()
429 && fa_depr_result.is_balanced()
430 && inventory_result.is_balanced();
431
432 let total_difference = ar_result.difference.abs()
433 + ap_result.difference.abs()
434 + fa_asset_result.difference.abs()
435 + fa_depr_result.difference.abs()
436 + inventory_result.difference.abs();
437
438 FullReconciliationReport {
439 company_code: company_code.to_string(),
440 as_of_date,
441 ar: ar_result,
442 ap: ap_result,
443 fa_assets: fa_asset_result,
444 fa_depreciation: fa_depr_result,
445 inventory: inventory_result,
446 all_reconciled,
447 total_difference,
448 }
449 }
450}
451
452#[derive(Debug, Clone)]
454pub struct FullReconciliationReport {
455 pub company_code: String,
457 pub as_of_date: NaiveDate,
459 pub ar: ReconciliationResult,
461 pub ap: ReconciliationResult,
463 pub fa_assets: ReconciliationResult,
465 pub fa_depreciation: ReconciliationResult,
467 pub inventory: ReconciliationResult,
469 pub all_reconciled: bool,
471 pub total_difference: Decimal,
473}
474
475impl FullReconciliationReport {
476 pub fn summary(&self) -> String {
478 format!(
479 "Reconciliation Report for {} as of {}\n\
480 AR: {} (diff: {})\n\
481 AP: {} (diff: {})\n\
482 FA Assets: {} (diff: {})\n\
483 FA Depreciation: {} (diff: {})\n\
484 Inventory: {} (diff: {})\n\
485 Overall: {} (total diff: {})",
486 self.company_code,
487 self.as_of_date,
488 status_str(&self.ar.status),
489 self.ar.difference,
490 status_str(&self.ap.status),
491 self.ap.difference,
492 status_str(&self.fa_assets.status),
493 self.fa_assets.difference,
494 status_str(&self.fa_depreciation.status),
495 self.fa_depreciation.difference,
496 status_str(&self.inventory.status),
497 self.inventory.difference,
498 if self.all_reconciled {
499 "RECONCILED"
500 } else {
501 "UNRECONCILED"
502 },
503 self.total_difference
504 )
505 }
506}
507
508fn status_str(status: &ReconStatus) -> &'static str {
509 match status {
510 ReconStatus::Reconciled => "RECONCILED",
511 ReconStatus::Unreconciled => "UNRECONCILED",
512 ReconStatus::PartiallyReconciled => "PARTIAL",
513 ReconStatus::InProgress => "IN PROGRESS",
514 }
515}
516
517#[cfg(test)]
518mod tests {
519 use super::*;
520
521 #[test]
522 fn test_reconciliation_balanced() {
523 let result = ReconciliationResult {
524 reconciliation_id: "TEST-001".to_string(),
525 company_code: "1000".to_string(),
526 subledger_type: SubledgerType::AR,
527 as_of_date: NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(),
528 gl_account: "1200".to_string(),
529 gl_balance: dec!(10000),
530 subledger_balance: dec!(10000),
531 difference: Decimal::ZERO,
532 status: ReconStatus::Reconciled,
533 unreconciled_items: Vec::new(),
534 reconciliation_date: NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(),
535 reconciled_by: None,
536 notes: None,
537 };
538
539 assert!(result.is_balanced());
540 }
541
542 #[test]
543 fn test_reconciliation_unbalanced() {
544 let result = ReconciliationResult {
545 reconciliation_id: "TEST-002".to_string(),
546 company_code: "1000".to_string(),
547 subledger_type: SubledgerType::AR,
548 as_of_date: NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(),
549 gl_account: "1200".to_string(),
550 gl_balance: dec!(10000),
551 subledger_balance: dec!(9500),
552 difference: dec!(500),
553 status: ReconStatus::Unreconciled,
554 unreconciled_items: Vec::new(),
555 reconciliation_date: NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(),
556 reconciled_by: None,
557 notes: None,
558 };
559
560 assert!(!result.is_balanced());
561 }
562}