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