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.get_mut(bucket).unwrap() += amount;
130 }
131
132 customer_details.push(aging);
133 }
134
135 customer_details.sort_by(|a, b| b.total_balance.cmp(&a.total_balance));
137
138 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 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 pub fn top_customers(&self, n: usize) -> Vec<&CustomerAging> {
178 self.customer_details.iter().take(n).collect()
179 }
180
181 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#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct CustomerAging {
193 pub customer_id: String,
195 pub customer_name: String,
197 pub credit_limit: Option<Decimal>,
199 pub total_balance: Decimal,
201 pub bucket_amounts: HashMap<AgingBucket, Decimal>,
203 pub invoice_counts: HashMap<AgingBucket, u32>,
205 pub oldest_invoice_date: Option<NaiveDate>,
207 pub weighted_avg_days: Decimal,
209 pub invoices: Vec<AgingInvoiceDetail>,
211}
212
213impl CustomerAging {
214 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 let days_outstanding = (as_of_date - invoice.invoice_date).num_days();
244 total_days_weighted += Decimal::from(days_outstanding) * amount;
245
246 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 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 pub fn with_credit_limit(mut self, limit: Decimal) -> Self {
285 self.credit_limit = Some(limit);
286 self
287 }
288
289 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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
328pub struct AgingInvoiceDetail {
329 pub invoice_number: String,
331 pub invoice_date: NaiveDate,
333 pub due_date: NaiveDate,
335 pub amount_remaining: Decimal,
337 pub days_overdue: i64,
339 pub bucket: AgingBucket,
341}
342
343#[derive(Debug, Clone, Serialize, Deserialize)]
345pub struct BadDebtReserve {
346 pub company_code: String,
348 pub as_of_date: NaiveDate,
350 pub reserve_rates: HashMap<AgingBucket, Decimal>,
352 pub reserves_by_bucket: HashMap<AgingBucket, Decimal>,
354 pub total_reserve: Decimal,
356 pub total_ar_balance: Decimal,
358 pub reserve_percentage: Decimal,
360}
361
362impl BadDebtReserve {
363 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
415pub struct DSOCalculation {
416 pub company_code: String,
418 pub period_start: NaiveDate,
420 pub period_end: NaiveDate,
422 pub average_ar: Decimal,
424 pub total_revenue: Decimal,
426 pub dso_days: Decimal,
428 pub prior_period_dso: Option<Decimal>,
430 pub dso_change: Option<Decimal>,
432}
433
434impl DSOCalculation {
435 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 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 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 assert!(dso.dso_days > Decimal::ZERO);
573 }
574}