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