Skip to main content

datasynth_core/models/subledger/ar/
aging.rs

1//! AR Aging analysis model.
2
3use chrono::NaiveDate;
4use rust_decimal::Decimal;
5use rust_decimal_macros::dec;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9use super::ARInvoice;
10use crate::models::subledger::SubledgerDocumentStatus;
11
12/// Aging bucket definition.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
14pub enum AgingBucket {
15    /// Not yet due.
16    Current,
17    /// 1-30 days overdue.
18    Days1To30,
19    /// 31-60 days overdue.
20    Days31To60,
21    /// 61-90 days overdue.
22    Days61To90,
23    /// Over 90 days overdue.
24    Over90Days,
25}
26
27impl AgingBucket {
28    /// Gets all buckets in order.
29    pub fn all() -> Vec<AgingBucket> {
30        vec![
31            AgingBucket::Current,
32            AgingBucket::Days1To30,
33            AgingBucket::Days31To60,
34            AgingBucket::Days61To90,
35            AgingBucket::Over90Days,
36        ]
37    }
38
39    /// Gets bucket name.
40    pub fn name(&self) -> &'static str {
41        match self {
42            AgingBucket::Current => "Current",
43            AgingBucket::Days1To30 => "1-30 Days",
44            AgingBucket::Days31To60 => "31-60 Days",
45            AgingBucket::Days61To90 => "61-90 Days",
46            AgingBucket::Over90Days => "Over 90 Days",
47        }
48    }
49
50    /// Determines bucket from days overdue.
51    pub fn from_days_overdue(days: i64) -> Self {
52        if days <= 0 {
53            AgingBucket::Current
54        } else if days <= 30 {
55            AgingBucket::Days1To30
56        } else if days <= 60 {
57            AgingBucket::Days31To60
58        } else if days <= 90 {
59            AgingBucket::Days61To90
60        } else {
61            AgingBucket::Over90Days
62        }
63    }
64}
65
66/// AR Aging report for a company.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct ARAgingReport {
69    /// Company code.
70    pub company_code: String,
71    /// As-of date for aging calculation.
72    pub as_of_date: NaiveDate,
73    /// Customer aging details.
74    pub customer_details: Vec<CustomerAging>,
75    /// Summary by bucket.
76    pub bucket_totals: HashMap<AgingBucket, Decimal>,
77    /// Total AR balance.
78    pub total_ar_balance: Decimal,
79    /// Total current.
80    pub total_current: Decimal,
81    /// Total overdue.
82    pub total_overdue: Decimal,
83    /// Percentage overdue.
84    pub overdue_percentage: Decimal,
85    /// Generated timestamp.
86    pub generated_at: chrono::DateTime<chrono::Utc>,
87}
88
89impl ARAgingReport {
90    /// Creates an aging report from invoices.
91    pub fn from_invoices(
92        company_code: String,
93        invoices: &[ARInvoice],
94        as_of_date: NaiveDate,
95    ) -> Self {
96        // Group invoices by customer
97        let mut customer_invoices: HashMap<String, Vec<&ARInvoice>> = HashMap::new();
98        for invoice in invoices.iter().filter(|i| {
99            i.company_code == company_code
100                && matches!(
101                    i.status,
102                    SubledgerDocumentStatus::Open | SubledgerDocumentStatus::PartiallyCleared
103                )
104        }) {
105            customer_invoices
106                .entry(invoice.customer_id.clone())
107                .or_default()
108                .push(invoice);
109        }
110
111        // Calculate customer aging
112        let mut customer_details = Vec::new();
113        let mut bucket_totals: HashMap<AgingBucket, Decimal> = AgingBucket::all()
114            .into_iter()
115            .map(|b| (b, Decimal::ZERO))
116            .collect();
117
118        for (customer_id, invoices) in customer_invoices {
119            let customer_name = invoices
120                .first()
121                .map(|i| i.customer_name.clone())
122                .unwrap_or_default();
123
124            let aging =
125                CustomerAging::from_invoices(customer_id, customer_name, &invoices, as_of_date);
126
127            // Add to bucket totals
128            for (bucket, amount) in &aging.bucket_amounts {
129                *bucket_totals
130                    .get_mut(bucket)
131                    .expect("bucket initialized in map") += amount;
132            }
133
134            customer_details.push(aging);
135        }
136
137        // Sort by total balance descending
138        customer_details.sort_by(|a, b| b.total_balance.cmp(&a.total_balance));
139
140        // Calculate totals
141        let total_ar_balance: Decimal = bucket_totals.values().sum();
142        let total_current = bucket_totals
143            .get(&AgingBucket::Current)
144            .copied()
145            .unwrap_or_default();
146        let total_overdue = total_ar_balance - total_current;
147        let overdue_percentage = if total_ar_balance > Decimal::ZERO {
148            (total_overdue / total_ar_balance * dec!(100)).round_dp(2)
149        } else {
150            Decimal::ZERO
151        };
152
153        Self {
154            company_code,
155            as_of_date,
156            customer_details,
157            bucket_totals,
158            total_ar_balance,
159            total_current,
160            total_overdue,
161            overdue_percentage,
162            generated_at: chrono::Utc::now(),
163        }
164    }
165
166    /// Gets customers with balance over threshold in specific bucket.
167    pub fn customers_in_bucket(
168        &self,
169        bucket: AgingBucket,
170        min_amount: Decimal,
171    ) -> Vec<&CustomerAging> {
172        self.customer_details
173            .iter()
174            .filter(|c| c.bucket_amounts.get(&bucket).copied().unwrap_or_default() >= min_amount)
175            .collect()
176    }
177
178    /// Gets top N customers by total balance.
179    pub fn top_customers(&self, n: usize) -> Vec<&CustomerAging> {
180        self.customer_details.iter().take(n).collect()
181    }
182
183    /// Gets customers exceeding credit limit.
184    pub fn over_credit_limit(&self) -> Vec<&CustomerAging> {
185        self.customer_details
186            .iter()
187            .filter(|c| c.is_over_credit_limit())
188            .collect()
189    }
190}
191
192/// Aging detail for a single customer.
193#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct CustomerAging {
195    /// Customer ID.
196    pub customer_id: String,
197    /// Customer name.
198    pub customer_name: String,
199    /// Credit limit.
200    pub credit_limit: Option<Decimal>,
201    /// Total balance.
202    pub total_balance: Decimal,
203    /// Amounts by bucket.
204    pub bucket_amounts: HashMap<AgingBucket, Decimal>,
205    /// Invoice count by bucket.
206    pub invoice_counts: HashMap<AgingBucket, u32>,
207    /// Oldest invoice date.
208    pub oldest_invoice_date: Option<NaiveDate>,
209    /// Weighted average days outstanding.
210    pub weighted_avg_days: Decimal,
211    /// Invoice details.
212    pub invoices: Vec<AgingInvoiceDetail>,
213}
214
215impl CustomerAging {
216    /// Creates customer aging from invoices.
217    pub fn from_invoices(
218        customer_id: String,
219        customer_name: String,
220        invoices: &[&ARInvoice],
221        as_of_date: NaiveDate,
222    ) -> Self {
223        let mut bucket_amounts: HashMap<AgingBucket, Decimal> = AgingBucket::all()
224            .into_iter()
225            .map(|b| (b, Decimal::ZERO))
226            .collect();
227        let mut invoice_counts: HashMap<AgingBucket, u32> =
228            AgingBucket::all().into_iter().map(|b| (b, 0)).collect();
229
230        let mut invoice_details = Vec::new();
231        let mut total_days_weighted = Decimal::ZERO;
232        let mut total_balance = Decimal::ZERO;
233        let mut oldest_date: Option<NaiveDate> = None;
234
235        for invoice in invoices {
236            let days_overdue = invoice.days_overdue(as_of_date);
237            let bucket = AgingBucket::from_days_overdue(days_overdue);
238            let amount = invoice.amount_remaining;
239
240            *bucket_amounts
241                .get_mut(&bucket)
242                .expect("bucket initialized in map") += amount;
243            *invoice_counts
244                .get_mut(&bucket)
245                .expect("bucket initialized in map") += 1;
246            total_balance += amount;
247
248            // Weighted average calculation
249            let days_outstanding = (as_of_date - invoice.invoice_date).num_days();
250            total_days_weighted += Decimal::from(days_outstanding) * amount;
251
252            // Track oldest invoice
253            if oldest_date.is_none_or(|d| invoice.invoice_date < d) {
254                oldest_date = Some(invoice.invoice_date);
255            }
256
257            invoice_details.push(AgingInvoiceDetail {
258                invoice_number: invoice.invoice_number.clone(),
259                invoice_date: invoice.invoice_date,
260                due_date: invoice.due_date,
261                amount_remaining: amount,
262                days_overdue,
263                bucket,
264            });
265        }
266
267        // Sort invoices by days overdue descending
268        invoice_details.sort_by(|a, b| b.days_overdue.cmp(&a.days_overdue));
269
270        let weighted_avg_days = if total_balance > Decimal::ZERO {
271            (total_days_weighted / total_balance).round_dp(1)
272        } else {
273            Decimal::ZERO
274        };
275
276        Self {
277            customer_id,
278            customer_name,
279            credit_limit: None,
280            total_balance,
281            bucket_amounts,
282            invoice_counts,
283            oldest_invoice_date: oldest_date,
284            weighted_avg_days,
285            invoices: invoice_details,
286        }
287    }
288
289    /// Sets credit limit.
290    pub fn with_credit_limit(mut self, limit: Decimal) -> Self {
291        self.credit_limit = Some(limit);
292        self
293    }
294
295    /// Checks if over credit limit.
296    pub fn is_over_credit_limit(&self) -> bool {
297        self.credit_limit
298            .map(|limit| self.total_balance > limit)
299            .unwrap_or(false)
300    }
301
302    /// Gets credit utilization percentage.
303    pub fn credit_utilization(&self) -> Option<Decimal> {
304        self.credit_limit.map(|limit| {
305            if limit > Decimal::ZERO {
306                (self.total_balance / limit * dec!(100)).round_dp(2)
307            } else {
308                Decimal::ZERO
309            }
310        })
311    }
312
313    /// Gets amount in a specific bucket.
314    pub fn amount_in_bucket(&self, bucket: AgingBucket) -> Decimal {
315        self.bucket_amounts
316            .get(&bucket)
317            .copied()
318            .unwrap_or_default()
319    }
320
321    /// Gets percentage in a specific bucket.
322    pub fn percentage_in_bucket(&self, bucket: AgingBucket) -> Decimal {
323        if self.total_balance > Decimal::ZERO {
324            let bucket_amount = self.amount_in_bucket(bucket);
325            (bucket_amount / self.total_balance * dec!(100)).round_dp(2)
326        } else {
327            Decimal::ZERO
328        }
329    }
330}
331
332/// Invoice detail for aging.
333#[derive(Debug, Clone, Serialize, Deserialize)]
334pub struct AgingInvoiceDetail {
335    /// Invoice number.
336    pub invoice_number: String,
337    /// Invoice date.
338    pub invoice_date: NaiveDate,
339    /// Due date.
340    pub due_date: NaiveDate,
341    /// Amount remaining.
342    pub amount_remaining: Decimal,
343    /// Days overdue.
344    pub days_overdue: i64,
345    /// Aging bucket.
346    pub bucket: AgingBucket,
347}
348
349/// Bad debt reserve calculation.
350#[derive(Debug, Clone, Serialize, Deserialize)]
351pub struct BadDebtReserve {
352    /// Company code.
353    pub company_code: String,
354    /// As-of date.
355    pub as_of_date: NaiveDate,
356    /// Reserve percentages by bucket.
357    pub reserve_rates: HashMap<AgingBucket, Decimal>,
358    /// Calculated reserves by bucket.
359    pub reserves_by_bucket: HashMap<AgingBucket, Decimal>,
360    /// Total reserve.
361    pub total_reserve: Decimal,
362    /// Total AR balance.
363    pub total_ar_balance: Decimal,
364    /// Reserve as percentage of AR.
365    pub reserve_percentage: Decimal,
366}
367
368impl BadDebtReserve {
369    /// Calculates bad debt reserve from aging report.
370    pub fn calculate(
371        aging_report: &ARAgingReport,
372        reserve_rates: HashMap<AgingBucket, Decimal>,
373    ) -> Self {
374        let mut reserves_by_bucket = HashMap::new();
375        let mut total_reserve = Decimal::ZERO;
376
377        for bucket in AgingBucket::all() {
378            let balance = aging_report
379                .bucket_totals
380                .get(&bucket)
381                .copied()
382                .unwrap_or_default();
383            let rate = reserve_rates.get(&bucket).copied().unwrap_or_default();
384            let reserve = (balance * rate / dec!(100)).round_dp(2);
385
386            reserves_by_bucket.insert(bucket, reserve);
387            total_reserve += reserve;
388        }
389
390        let reserve_percentage = if aging_report.total_ar_balance > Decimal::ZERO {
391            (total_reserve / aging_report.total_ar_balance * dec!(100)).round_dp(2)
392        } else {
393            Decimal::ZERO
394        };
395
396        Self {
397            company_code: aging_report.company_code.clone(),
398            as_of_date: aging_report.as_of_date,
399            reserve_rates,
400            reserves_by_bucket,
401            total_reserve,
402            total_ar_balance: aging_report.total_ar_balance,
403            reserve_percentage,
404        }
405    }
406
407    /// Default reserve rates.
408    pub fn default_rates() -> HashMap<AgingBucket, Decimal> {
409        let mut rates = HashMap::new();
410        rates.insert(AgingBucket::Current, dec!(0.5));
411        rates.insert(AgingBucket::Days1To30, dec!(2));
412        rates.insert(AgingBucket::Days31To60, dec!(5));
413        rates.insert(AgingBucket::Days61To90, dec!(15));
414        rates.insert(AgingBucket::Over90Days, dec!(50));
415        rates
416    }
417}
418
419/// DSO (Days Sales Outstanding) calculation.
420#[derive(Debug, Clone, Serialize, Deserialize)]
421pub struct DSOCalculation {
422    /// Company code.
423    pub company_code: String,
424    /// Calculation period start.
425    pub period_start: NaiveDate,
426    /// Calculation period end.
427    pub period_end: NaiveDate,
428    /// Average AR balance.
429    pub average_ar: Decimal,
430    /// Total revenue for period.
431    pub total_revenue: Decimal,
432    /// DSO result.
433    pub dso_days: Decimal,
434    /// Prior period DSO for comparison.
435    pub prior_period_dso: Option<Decimal>,
436    /// DSO change.
437    pub dso_change: Option<Decimal>,
438}
439
440impl DSOCalculation {
441    /// Calculates DSO.
442    pub fn calculate(
443        company_code: String,
444        period_start: NaiveDate,
445        period_end: NaiveDate,
446        beginning_ar: Decimal,
447        ending_ar: Decimal,
448        total_revenue: Decimal,
449    ) -> Self {
450        let average_ar = (beginning_ar + ending_ar) / dec!(2);
451        let days_in_period = (period_end - period_start).num_days();
452
453        let dso_days = if total_revenue > Decimal::ZERO {
454            (average_ar / total_revenue * Decimal::from(days_in_period)).round_dp(1)
455        } else {
456            Decimal::ZERO
457        };
458
459        Self {
460            company_code,
461            period_start,
462            period_end,
463            average_ar,
464            total_revenue,
465            dso_days,
466            prior_period_dso: None,
467            dso_change: None,
468        }
469    }
470
471    /// Sets prior period comparison.
472    pub fn with_prior_period(mut self, prior_dso: Decimal) -> Self {
473        self.prior_period_dso = Some(prior_dso);
474        self.dso_change = Some(self.dso_days - prior_dso);
475        self
476    }
477
478    /// Checks if DSO improved (decreased).
479    pub fn is_improved(&self) -> Option<bool> {
480        self.dso_change.map(|change| change < Decimal::ZERO)
481    }
482}
483
484#[cfg(test)]
485#[allow(clippy::unwrap_used)]
486mod tests {
487    use super::*;
488    use crate::models::subledger::PaymentTerms;
489
490    fn create_test_invoices() -> Vec<ARInvoice> {
491        vec![
492            {
493                let mut inv = ARInvoice::new(
494                    "INV001".to_string(),
495                    "1000".to_string(),
496                    "CUST001".to_string(),
497                    "Customer A".to_string(),
498                    NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
499                    PaymentTerms::net_30(),
500                    "USD".to_string(),
501                );
502                inv.amount_remaining = dec!(1000);
503                inv
504            },
505            {
506                let mut inv = ARInvoice::new(
507                    "INV002".to_string(),
508                    "1000".to_string(),
509                    "CUST001".to_string(),
510                    "Customer A".to_string(),
511                    NaiveDate::from_ymd_opt(2023, 11, 1).unwrap(),
512                    PaymentTerms::net_30(),
513                    "USD".to_string(),
514                );
515                inv.amount_remaining = dec!(500);
516                inv
517            },
518            {
519                let mut inv = ARInvoice::new(
520                    "INV003".to_string(),
521                    "1000".to_string(),
522                    "CUST002".to_string(),
523                    "Customer B".to_string(),
524                    NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
525                    PaymentTerms::net_30(),
526                    "USD".to_string(),
527                );
528                inv.amount_remaining = dec!(2000);
529                inv
530            },
531        ]
532    }
533
534    #[test]
535    fn test_aging_bucket_from_days() {
536        assert_eq!(AgingBucket::from_days_overdue(-5), AgingBucket::Current);
537        assert_eq!(AgingBucket::from_days_overdue(0), AgingBucket::Current);
538        assert_eq!(AgingBucket::from_days_overdue(15), AgingBucket::Days1To30);
539        assert_eq!(AgingBucket::from_days_overdue(45), AgingBucket::Days31To60);
540        assert_eq!(AgingBucket::from_days_overdue(75), AgingBucket::Days61To90);
541        assert_eq!(AgingBucket::from_days_overdue(120), AgingBucket::Over90Days);
542    }
543
544    #[test]
545    fn test_aging_report() {
546        let invoices = create_test_invoices();
547        let as_of_date = NaiveDate::from_ymd_opt(2024, 2, 15).unwrap();
548
549        let report = ARAgingReport::from_invoices("1000".to_string(), &invoices, as_of_date);
550
551        assert_eq!(report.total_ar_balance, dec!(3500));
552        assert_eq!(report.customer_details.len(), 2);
553    }
554
555    #[test]
556    fn test_bad_debt_reserve() {
557        let invoices = create_test_invoices();
558        let as_of_date = NaiveDate::from_ymd_opt(2024, 2, 15).unwrap();
559        let report = ARAgingReport::from_invoices("1000".to_string(), &invoices, as_of_date);
560
561        let reserve = BadDebtReserve::calculate(&report, BadDebtReserve::default_rates());
562
563        assert!(reserve.total_reserve > Decimal::ZERO);
564    }
565
566    #[test]
567    fn test_dso_calculation() {
568        let dso = DSOCalculation::calculate(
569            "1000".to_string(),
570            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
571            NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(),
572            dec!(100_000),
573            dec!(120_000),
574            dec!(500_000),
575        );
576
577        // Average AR = 110,000, Revenue = 500,000, Days = 31
578        // DSO = (110,000 / 500,000) * 31 = 6.82
579        assert!(dso.dso_days > Decimal::ZERO);
580    }
581}