1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
14pub enum AgingBucket {
15 Current,
17 Days1To30,
19 Days31To60,
21 Days61To90,
23 Over90Days,
25}
26
27impl AgingBucket {
28 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct ARAgingReport {
69 pub company_code: String,
71 pub as_of_date: NaiveDate,
73 pub customer_details: Vec<CustomerAging>,
75 pub bucket_totals: HashMap<AgingBucket, Decimal>,
77 pub total_ar_balance: Decimal,
79 pub total_current: Decimal,
81 pub total_overdue: Decimal,
83 pub overdue_percentage: Decimal,
85 pub generated_at: chrono::DateTime<chrono::Utc>,
87}
88
89impl ARAgingReport {
90 pub fn from_invoices(
92 company_code: String,
93 invoices: &[ARInvoice],
94 as_of_date: NaiveDate,
95 ) -> Self {
96 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 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 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 customer_details.sort_by(|a, b| b.total_balance.cmp(&a.total_balance));
139
140 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 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 pub fn top_customers(&self, n: usize) -> Vec<&CustomerAging> {
180 self.customer_details.iter().take(n).collect()
181 }
182
183 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#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct CustomerAging {
195 pub customer_id: String,
197 pub customer_name: String,
199 pub credit_limit: Option<Decimal>,
201 pub total_balance: Decimal,
203 pub bucket_amounts: HashMap<AgingBucket, Decimal>,
205 pub invoice_counts: HashMap<AgingBucket, u32>,
207 pub oldest_invoice_date: Option<NaiveDate>,
209 pub weighted_avg_days: Decimal,
211 pub invoices: Vec<AgingInvoiceDetail>,
213}
214
215impl CustomerAging {
216 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 let days_outstanding = (as_of_date - invoice.invoice_date).num_days();
250 total_days_weighted += Decimal::from(days_outstanding) * amount;
251
252 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 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 pub fn with_credit_limit(mut self, limit: Decimal) -> Self {
291 self.credit_limit = Some(limit);
292 self
293 }
294
295 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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
334pub struct AgingInvoiceDetail {
335 pub invoice_number: String,
337 pub invoice_date: NaiveDate,
339 pub due_date: NaiveDate,
341 pub amount_remaining: Decimal,
343 pub days_overdue: i64,
345 pub bucket: AgingBucket,
347}
348
349#[derive(Debug, Clone, Serialize, Deserialize)]
351pub struct BadDebtReserve {
352 pub company_code: String,
354 pub as_of_date: NaiveDate,
356 pub reserve_rates: HashMap<AgingBucket, Decimal>,
358 pub reserves_by_bucket: HashMap<AgingBucket, Decimal>,
360 pub total_reserve: Decimal,
362 pub total_ar_balance: Decimal,
364 pub reserve_percentage: Decimal,
366}
367
368impl BadDebtReserve {
369 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
421pub struct DSOCalculation {
422 pub company_code: String,
424 pub period_start: NaiveDate,
426 pub period_end: NaiveDate,
428 pub average_ar: Decimal,
430 pub total_revenue: Decimal,
432 pub dso_days: Decimal,
434 pub prior_period_dso: Option<Decimal>,
436 pub dso_change: Option<Decimal>,
438}
439
440impl DSOCalculation {
441 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 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 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 assert!(dso.dso_days > Decimal::ZERO);
580 }
581}