Skip to main content

datasynth_generators/subledger/
reconciliation.rs

1//! GL-to-Subledger reconciliation module.
2
3use 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/// Local status enum for reconciliation results.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum ReconStatus {
18    /// Fully reconciled within tolerance.
19    Reconciled,
20    /// Partially reconciled (some items identified).
21    PartiallyReconciled,
22    /// Not reconciled.
23    Unreconciled,
24    /// Reconciliation in progress.
25    InProgress,
26}
27
28/// An item that doesn't reconcile.
29#[derive(Debug, Clone)]
30pub struct UnreconciledEntry {
31    /// Type of discrepancy.
32    pub entry_type: String,
33    /// Document number.
34    pub document_number: String,
35    /// Amount.
36    pub amount: Decimal,
37    /// Description of discrepancy.
38    pub description: String,
39}
40
41/// Result of a GL-to-subledger reconciliation.
42#[derive(Debug, Clone)]
43pub struct ReconciliationResult {
44    /// Reconciliation ID.
45    pub reconciliation_id: String,
46    /// Company code.
47    pub company_code: String,
48    /// Subledger type.
49    pub subledger_type: SubledgerType,
50    /// As-of date.
51    pub as_of_date: NaiveDate,
52    /// GL account code.
53    pub gl_account: String,
54    /// GL balance.
55    pub gl_balance: Decimal,
56    /// Subledger balance.
57    pub subledger_balance: Decimal,
58    /// Difference.
59    pub difference: Decimal,
60    /// Status.
61    pub status: ReconStatus,
62    /// Unreconciled items.
63    pub unreconciled_items: Vec<UnreconciledEntry>,
64    /// Reconciliation date.
65    pub reconciliation_date: NaiveDate,
66    /// Reconciled by.
67    pub reconciled_by: Option<String>,
68    /// Notes.
69    pub notes: Option<String>,
70}
71
72impl ReconciliationResult {
73    /// Returns true if the reconciliation is balanced.
74    pub fn is_balanced(&self) -> bool {
75        self.difference.abs() < dec!(0.01)
76    }
77}
78
79/// Configuration for reconciliation.
80#[derive(Debug, Clone)]
81pub struct ReconciliationConfig {
82    /// Tolerance amount for auto-reconciliation.
83    pub tolerance_amount: Decimal,
84    /// AR control account.
85    pub ar_control_account: String,
86    /// AP control account.
87    pub ap_control_account: String,
88    /// FA control account.
89    pub fa_control_account: String,
90    /// Inventory control account.
91    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
106/// Reconciliation engine for GL-to-subledger matching.
107pub struct ReconciliationEngine {
108    config: ReconciliationConfig,
109    reconciliation_counter: u64,
110}
111
112impl ReconciliationEngine {
113    /// Creates a new reconciliation engine.
114    pub fn new(config: ReconciliationConfig) -> Self {
115        Self {
116            config,
117            reconciliation_counter: 0,
118        }
119    }
120
121    /// Reconciles AR subledger to GL.
122    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        // Check for timing differences or mismatches
139        if difference.abs() >= self.config.tolerance_amount {
140            // Find potential unreconciled items
141            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    /// Reconciles AP subledger to GL.
182    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    /// Reconciles FA subledger to GL.
240    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        // Asset reconciliation
249        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        // Accumulated depreciation reconciliation
280        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    /// Reconciles inventory subledger to GL.
313    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            // Check for positions with zero value but quantity
331            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    /// Performs full reconciliation for all subledgers.
374    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/// Full reconciliation report covering all subledgers.
453#[derive(Debug, Clone)]
454pub struct FullReconciliationReport {
455    /// Company code.
456    pub company_code: String,
457    /// As-of date.
458    pub as_of_date: NaiveDate,
459    /// AR reconciliation result.
460    pub ar: ReconciliationResult,
461    /// AP reconciliation result.
462    pub ap: ReconciliationResult,
463    /// FA assets reconciliation result.
464    pub fa_assets: ReconciliationResult,
465    /// FA depreciation reconciliation result.
466    pub fa_depreciation: ReconciliationResult,
467    /// Inventory reconciliation result.
468    pub inventory: ReconciliationResult,
469    /// Whether all subledgers are reconciled.
470    pub all_reconciled: bool,
471    /// Total unreconciled difference across all subledgers.
472    pub total_difference: Decimal,
473}
474
475impl FullReconciliationReport {
476    /// Returns a summary of the reconciliation status.
477    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}