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