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