1use chrono::{Datelike, NaiveDate};
4use rust_decimal::Decimal;
5use rust_decimal_macros::dec;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9use super::APInvoice;
10use crate::models::subledger::SubledgerDocumentStatus;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
14pub enum APAgingBucket {
15 Current,
17 Days1To30,
19 Days31To60,
21 Days61To90,
23 Over90Days,
25}
26
27impl APAgingBucket {
28 pub fn all() -> Vec<APAgingBucket> {
30 vec![
31 APAgingBucket::Current,
32 APAgingBucket::Days1To30,
33 APAgingBucket::Days31To60,
34 APAgingBucket::Days61To90,
35 APAgingBucket::Over90Days,
36 ]
37 }
38
39 pub fn name(&self) -> &'static str {
41 match self {
42 APAgingBucket::Current => "Current",
43 APAgingBucket::Days1To30 => "1-30 Days",
44 APAgingBucket::Days31To60 => "31-60 Days",
45 APAgingBucket::Days61To90 => "61-90 Days",
46 APAgingBucket::Over90Days => "Over 90 Days",
47 }
48 }
49
50 pub fn from_days_overdue(days: i64) -> Self {
52 if days <= 0 {
53 APAgingBucket::Current
54 } else if days <= 30 {
55 APAgingBucket::Days1To30
56 } else if days <= 60 {
57 APAgingBucket::Days31To60
58 } else if days <= 90 {
59 APAgingBucket::Days61To90
60 } else {
61 APAgingBucket::Over90Days
62 }
63 }
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct APAgingReport {
69 pub company_code: String,
71 pub as_of_date: NaiveDate,
73 pub vendor_details: Vec<VendorAging>,
75 pub bucket_totals: HashMap<APAgingBucket, Decimal>,
77 pub total_ap_balance: Decimal,
79 pub total_current: Decimal,
81 pub total_overdue: Decimal,
83 pub generated_at: chrono::DateTime<chrono::Utc>,
85}
86
87impl APAgingReport {
88 pub fn from_invoices(
90 company_code: String,
91 invoices: &[APInvoice],
92 as_of_date: NaiveDate,
93 ) -> Self {
94 let mut vendor_invoices: HashMap<String, Vec<&APInvoice>> = HashMap::new();
96 for invoice in invoices.iter().filter(|i| {
97 i.company_code == company_code
98 && matches!(
99 i.status,
100 SubledgerDocumentStatus::Open | SubledgerDocumentStatus::PartiallyCleared
101 )
102 }) {
103 vendor_invoices
104 .entry(invoice.vendor_id.clone())
105 .or_default()
106 .push(invoice);
107 }
108
109 let mut vendor_details = Vec::new();
110 let mut bucket_totals: HashMap<APAgingBucket, Decimal> = APAgingBucket::all()
111 .into_iter()
112 .map(|b| (b, Decimal::ZERO))
113 .collect();
114
115 for (vendor_id, invoices) in vendor_invoices {
116 let vendor_name = invoices
117 .first()
118 .map(|i| i.vendor_name.clone())
119 .unwrap_or_default();
120
121 let aging = VendorAging::from_invoices(vendor_id, vendor_name, &invoices, as_of_date);
122
123 for (bucket, amount) in &aging.bucket_amounts {
124 *bucket_totals
125 .get_mut(bucket)
126 .expect("bucket initialized in map") += amount;
127 }
128
129 vendor_details.push(aging);
130 }
131
132 vendor_details.sort_by(|a, b| b.total_balance.cmp(&a.total_balance));
133
134 let total_ap_balance: Decimal = bucket_totals.values().sum();
135 let total_current = bucket_totals
136 .get(&APAgingBucket::Current)
137 .copied()
138 .unwrap_or_default();
139 let total_overdue = total_ap_balance - total_current;
140
141 Self {
142 company_code,
143 as_of_date,
144 vendor_details,
145 bucket_totals,
146 total_ap_balance,
147 total_current,
148 total_overdue,
149 generated_at: chrono::Utc::now(),
150 }
151 }
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct VendorAging {
157 pub vendor_id: String,
159 pub vendor_name: String,
161 pub total_balance: Decimal,
163 pub bucket_amounts: HashMap<APAgingBucket, Decimal>,
165 pub invoice_counts: HashMap<APAgingBucket, u32>,
167 pub oldest_invoice_date: Option<NaiveDate>,
169 pub weighted_avg_days: Decimal,
171}
172
173impl VendorAging {
174 pub fn from_invoices(
176 vendor_id: String,
177 vendor_name: String,
178 invoices: &[&APInvoice],
179 as_of_date: NaiveDate,
180 ) -> Self {
181 let mut bucket_amounts: HashMap<APAgingBucket, Decimal> = APAgingBucket::all()
182 .into_iter()
183 .map(|b| (b, Decimal::ZERO))
184 .collect();
185 let mut invoice_counts: HashMap<APAgingBucket, u32> =
186 APAgingBucket::all().into_iter().map(|b| (b, 0)).collect();
187
188 let mut total_days_weighted = Decimal::ZERO;
189 let mut total_balance = Decimal::ZERO;
190 let mut oldest_date: Option<NaiveDate> = None;
191
192 for invoice in invoices {
193 let days_overdue = invoice.days_overdue(as_of_date);
194 let bucket = APAgingBucket::from_days_overdue(days_overdue);
195 let amount = invoice.amount_remaining;
196
197 *bucket_amounts
198 .get_mut(&bucket)
199 .expect("bucket initialized in map") += amount;
200 *invoice_counts
201 .get_mut(&bucket)
202 .expect("bucket initialized in map") += 1;
203 total_balance += amount;
204
205 let days_outstanding = (as_of_date - invoice.invoice_date).num_days();
206 total_days_weighted += Decimal::from(days_outstanding) * amount;
207
208 if oldest_date.is_none_or(|d| invoice.invoice_date < d) {
209 oldest_date = Some(invoice.invoice_date);
210 }
211 }
212
213 let weighted_avg_days = if total_balance > Decimal::ZERO {
214 (total_days_weighted / total_balance).round_dp(1)
215 } else {
216 Decimal::ZERO
217 };
218
219 Self {
220 vendor_id,
221 vendor_name,
222 total_balance,
223 bucket_amounts,
224 invoice_counts,
225 oldest_invoice_date: oldest_date,
226 weighted_avg_days,
227 }
228 }
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize)]
233pub struct APCashForecast {
234 pub company_code: String,
236 pub start_date: NaiveDate,
238 pub end_date: NaiveDate,
240 pub daily_forecast: Vec<DailyForecast>,
242 pub weekly_summary: Vec<WeeklyForecast>,
244 pub total_outflow: Decimal,
246 pub total_discount_opportunity: Decimal,
248 pub generated_at: chrono::DateTime<chrono::Utc>,
250}
251
252impl APCashForecast {
253 pub fn from_invoices(
255 company_code: String,
256 invoices: &[APInvoice],
257 start_date: NaiveDate,
258 end_date: NaiveDate,
259 include_discounts: bool,
260 ) -> Self {
261 let open_invoices: Vec<_> = invoices
262 .iter()
263 .filter(|i| {
264 i.company_code == company_code
265 && matches!(
266 i.status,
267 SubledgerDocumentStatus::Open | SubledgerDocumentStatus::PartiallyCleared
268 )
269 && i.due_date >= start_date
270 && i.due_date <= end_date
271 })
272 .collect();
273
274 let mut daily_map: HashMap<NaiveDate, DailyForecast> = HashMap::new();
276 let mut total_outflow = Decimal::ZERO;
277 let mut total_discount = Decimal::ZERO;
278
279 for invoice in open_invoices {
280 let amount = invoice.amount_remaining;
281 let discount = if include_discounts {
282 invoice.available_discount(start_date)
283 } else {
284 Decimal::ZERO
285 };
286
287 let entry = daily_map
288 .entry(invoice.due_date)
289 .or_insert_with(|| DailyForecast {
290 date: invoice.due_date,
291 amount_due: Decimal::ZERO,
292 invoice_count: 0,
293 discount_available: Decimal::ZERO,
294 vendor_count: 0,
295 vendors: Vec::new(),
296 });
297
298 entry.amount_due += amount;
299 entry.invoice_count += 1;
300 entry.discount_available += discount;
301 if !entry.vendors.contains(&invoice.vendor_id) {
302 entry.vendors.push(invoice.vendor_id.clone());
303 entry.vendor_count += 1;
304 }
305
306 total_outflow += amount;
307 total_discount += discount;
308 }
309
310 let mut daily_forecast: Vec<DailyForecast> = daily_map.into_values().collect();
312 daily_forecast.sort_by_key(|d| d.date);
313
314 let weekly_summary = Self::build_weekly_summary(&daily_forecast);
316
317 Self {
318 company_code,
319 start_date,
320 end_date,
321 daily_forecast,
322 weekly_summary,
323 total_outflow,
324 total_discount_opportunity: total_discount,
325 generated_at: chrono::Utc::now(),
326 }
327 }
328
329 fn build_weekly_summary(daily: &[DailyForecast]) -> Vec<WeeklyForecast> {
331 let mut weekly: HashMap<NaiveDate, WeeklyForecast> = HashMap::new();
332
333 for day in daily {
334 let weekday = day.date.weekday().num_days_from_monday();
336 let week_start = day.date - chrono::Duration::days(weekday as i64);
337
338 let entry = weekly.entry(week_start).or_insert_with(|| WeeklyForecast {
339 week_start,
340 week_end: week_start + chrono::Duration::days(6),
341 amount_due: Decimal::ZERO,
342 invoice_count: 0,
343 discount_available: Decimal::ZERO,
344 });
345
346 entry.amount_due += day.amount_due;
347 entry.invoice_count += day.invoice_count;
348 entry.discount_available += day.discount_available;
349 }
350
351 let mut result: Vec<WeeklyForecast> = weekly.into_values().collect();
352 result.sort_by_key(|w| w.week_start);
353 result
354 }
355}
356
357#[derive(Debug, Clone, Serialize, Deserialize)]
359pub struct DailyForecast {
360 pub date: NaiveDate,
362 pub amount_due: Decimal,
364 pub invoice_count: u32,
366 pub discount_available: Decimal,
368 pub vendor_count: u32,
370 pub vendors: Vec<String>,
372}
373
374#[derive(Debug, Clone, Serialize, Deserialize)]
376pub struct WeeklyForecast {
377 pub week_start: NaiveDate,
379 pub week_end: NaiveDate,
381 pub amount_due: Decimal,
383 pub invoice_count: u32,
385 pub discount_available: Decimal,
387}
388
389#[derive(Debug, Clone, Serialize, Deserialize)]
391pub struct DPOCalculation {
392 pub company_code: String,
394 pub period_start: NaiveDate,
396 pub period_end: NaiveDate,
398 pub average_ap: Decimal,
400 pub total_cogs: Decimal,
402 pub dpo_days: Decimal,
404 pub prior_period_dpo: Option<Decimal>,
406 pub dpo_change: Option<Decimal>,
408}
409
410impl DPOCalculation {
411 pub fn calculate(
413 company_code: String,
414 period_start: NaiveDate,
415 period_end: NaiveDate,
416 beginning_ap: Decimal,
417 ending_ap: Decimal,
418 total_cogs: Decimal,
419 ) -> Self {
420 let average_ap = (beginning_ap + ending_ap) / dec!(2);
421 let days_in_period = (period_end - period_start).num_days();
422
423 let dpo_days = if total_cogs > Decimal::ZERO {
424 (average_ap / total_cogs * Decimal::from(days_in_period)).round_dp(1)
425 } else {
426 Decimal::ZERO
427 };
428
429 Self {
430 company_code,
431 period_start,
432 period_end,
433 average_ap,
434 total_cogs,
435 dpo_days,
436 prior_period_dpo: None,
437 dpo_change: None,
438 }
439 }
440
441 pub fn with_prior_period(mut self, prior_dpo: Decimal) -> Self {
443 self.prior_period_dpo = Some(prior_dpo);
444 self.dpo_change = Some(self.dpo_days - prior_dpo);
445 self
446 }
447}
448
449#[derive(Debug, Clone, Serialize, Deserialize)]
451pub struct PaymentOptimization {
452 pub analysis_date: NaiveDate,
454 pub available_cash: Decimal,
456 pub recommended_payments: Vec<OptimizedPayment>,
458 pub total_payment: Decimal,
460 pub discount_captured: Decimal,
462 pub effective_discount_rate: Decimal,
464 pub deferred_invoices: Vec<DeferredInvoice>,
466}
467
468impl PaymentOptimization {
469 pub fn optimize(
471 invoices: &[APInvoice],
472 available_cash: Decimal,
473 analysis_date: NaiveDate,
474 company_code: &str,
475 ) -> Self {
476 let mut open_invoices: Vec<_> = invoices
477 .iter()
478 .filter(|i| {
479 i.company_code == company_code
480 && i.status == SubledgerDocumentStatus::Open
481 && i.is_payable()
482 })
483 .collect();
484
485 open_invoices.sort_by(|a, b| {
487 let a_discount_rate = if a.amount_remaining > Decimal::ZERO {
488 a.available_discount(analysis_date) / a.amount_remaining
489 } else {
490 Decimal::ZERO
491 };
492 let b_discount_rate = if b.amount_remaining > Decimal::ZERO {
493 b.available_discount(analysis_date) / b.amount_remaining
494 } else {
495 Decimal::ZERO
496 };
497 b_discount_rate.cmp(&a_discount_rate)
498 });
499
500 let mut remaining_cash = available_cash;
501 let mut recommended_payments = Vec::new();
502 let mut deferred_invoices = Vec::new();
503 let mut total_payment = Decimal::ZERO;
504 let mut discount_captured = Decimal::ZERO;
505
506 for invoice in open_invoices {
507 let discount = invoice.available_discount(analysis_date);
508 let payment_amount = invoice.amount_remaining - discount;
509
510 if payment_amount <= remaining_cash {
511 recommended_payments.push(OptimizedPayment {
512 vendor_id: invoice.vendor_id.clone(),
513 vendor_name: invoice.vendor_name.clone(),
514 invoice_number: invoice.invoice_number.clone(),
515 invoice_amount: invoice.amount_remaining,
516 payment_amount,
517 discount,
518 due_date: invoice.due_date,
519 priority: PaymentPriority::from_discount(discount, invoice.amount_remaining),
520 });
521
522 total_payment += payment_amount;
523 discount_captured += discount;
524 remaining_cash -= payment_amount;
525 } else {
526 deferred_invoices.push(DeferredInvoice {
527 vendor_id: invoice.vendor_id.clone(),
528 invoice_number: invoice.invoice_number.clone(),
529 amount: invoice.amount_remaining,
530 due_date: invoice.due_date,
531 discount_lost: discount,
532 });
533 }
534 }
535
536 let effective_discount_rate = if total_payment > Decimal::ZERO {
537 (discount_captured / (total_payment + discount_captured) * dec!(100)).round_dp(2)
538 } else {
539 Decimal::ZERO
540 };
541
542 Self {
543 analysis_date,
544 available_cash,
545 recommended_payments,
546 total_payment,
547 discount_captured,
548 effective_discount_rate,
549 deferred_invoices,
550 }
551 }
552}
553
554#[derive(Debug, Clone, Serialize, Deserialize)]
556pub struct OptimizedPayment {
557 pub vendor_id: String,
559 pub vendor_name: String,
561 pub invoice_number: String,
563 pub invoice_amount: Decimal,
565 pub payment_amount: Decimal,
567 pub discount: Decimal,
569 pub due_date: NaiveDate,
571 pub priority: PaymentPriority,
573}
574
575#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
577pub enum PaymentPriority {
578 High,
580 Medium,
582 Low,
584}
585
586impl PaymentPriority {
587 pub fn from_discount(discount: Decimal, amount: Decimal) -> Self {
589 if amount <= Decimal::ZERO {
590 return PaymentPriority::Low;
591 }
592 let rate = discount / amount * dec!(100);
593 if rate >= dec!(2) {
594 PaymentPriority::High
595 } else if rate >= dec!(1) {
596 PaymentPriority::Medium
597 } else {
598 PaymentPriority::Low
599 }
600 }
601}
602
603#[derive(Debug, Clone, Serialize, Deserialize)]
605pub struct DeferredInvoice {
606 pub vendor_id: String,
608 pub invoice_number: String,
610 pub amount: Decimal,
612 pub due_date: NaiveDate,
614 pub discount_lost: Decimal,
616}
617
618#[cfg(test)]
619#[allow(clippy::unwrap_used)]
620mod tests {
621 use super::*;
622 use crate::models::subledger::PaymentTerms;
623
624 fn create_test_invoices() -> Vec<APInvoice> {
625 vec![
626 {
627 let mut inv = APInvoice::new(
628 "AP001".to_string(),
629 "V001".to_string(),
630 "1000".to_string(),
631 "VEND001".to_string(),
632 "Vendor A".to_string(),
633 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
634 PaymentTerms::two_ten_net_30(),
635 "USD".to_string(),
636 );
637 inv.amount_remaining = dec!(1000);
638 inv
639 },
640 {
641 let mut inv = APInvoice::new(
642 "AP002".to_string(),
643 "V002".to_string(),
644 "1000".to_string(),
645 "VEND001".to_string(),
646 "Vendor A".to_string(),
647 NaiveDate::from_ymd_opt(2023, 12, 1).unwrap(),
648 PaymentTerms::net_30(),
649 "USD".to_string(),
650 );
651 inv.amount_remaining = dec!(500);
652 inv
653 },
654 ]
655 }
656
657 #[test]
658 fn test_ap_aging_report() {
659 let invoices = create_test_invoices();
660 let as_of_date = NaiveDate::from_ymd_opt(2024, 2, 15).unwrap();
661
662 let report = APAgingReport::from_invoices("1000".to_string(), &invoices, as_of_date);
663
664 assert_eq!(report.total_ap_balance, dec!(1500));
665 }
666
667 #[test]
668 fn test_cash_forecast() {
669 let invoices = create_test_invoices();
670 let start_date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
671 let end_date = NaiveDate::from_ymd_opt(2024, 3, 31).unwrap();
672
673 let forecast = APCashForecast::from_invoices(
674 "1000".to_string(),
675 &invoices,
676 start_date,
677 end_date,
678 true,
679 );
680
681 assert!(forecast.total_outflow > Decimal::ZERO);
682 }
683
684 #[test]
685 fn test_dpo_calculation() {
686 let dpo = DPOCalculation::calculate(
687 "1000".to_string(),
688 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
689 NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(),
690 dec!(50_000),
691 dec!(60_000),
692 dec!(300_000),
693 );
694
695 assert!(dpo.dpo_days > Decimal::ZERO);
698 }
699}