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();
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 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 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 customer_details.sort_by(|a, b| b.total_balance.cmp(&a.total_balance));
145
146 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 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 pub fn top_customers(&self, n: usize) -> Vec<&CustomerAging> {
186 self.customer_details.iter().take(n).collect()
187 }
188
189 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#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct CustomerAging {
201 pub customer_id: String,
203 pub customer_name: String,
205 pub credit_limit: Option<Decimal>,
207 pub total_balance: Decimal,
209 pub bucket_amounts: HashMap<AgingBucket, Decimal>,
211 pub invoice_counts: HashMap<AgingBucket, u32>,
213 pub oldest_invoice_date: Option<NaiveDate>,
215 pub weighted_avg_days: Decimal,
217 pub invoices: Vec<AgingInvoiceDetail>,
219}
220
221impl CustomerAging {
222 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 let days_outstanding = (as_of_date - invoice.invoice_date).num_days();
256 total_days_weighted += Decimal::from(days_outstanding) * amount;
257
258 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 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 pub fn with_credit_limit(mut self, limit: Decimal) -> Self {
297 self.credit_limit = Some(limit);
298 self
299 }
300
301 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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
340pub struct AgingInvoiceDetail {
341 pub invoice_number: String,
343 pub invoice_date: NaiveDate,
345 pub due_date: NaiveDate,
347 pub amount_remaining: Decimal,
349 pub days_overdue: i64,
351 pub bucket: AgingBucket,
353}
354
355#[derive(Debug, Clone, Serialize, Deserialize)]
357pub struct BadDebtReserve {
358 pub company_code: String,
360 pub as_of_date: NaiveDate,
362 pub reserve_rates: HashMap<AgingBucket, Decimal>,
364 pub reserves_by_bucket: HashMap<AgingBucket, Decimal>,
366 pub total_reserve: Decimal,
368 pub total_ar_balance: Decimal,
370 pub reserve_percentage: Decimal,
372}
373
374impl BadDebtReserve {
375 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
427pub struct DSOCalculation {
428 pub company_code: String,
430 pub period_start: NaiveDate,
432 pub period_end: NaiveDate,
434 pub average_ar: Decimal,
436 pub total_revenue: Decimal,
438 pub dso_days: Decimal,
440 pub prior_period_dso: Option<Decimal>,
442 pub dso_change: Option<Decimal>,
444}
445
446impl DSOCalculation {
447 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 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 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 assert!(dso.dso_days > Decimal::ZERO);
586 }
587}