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.get_mut(bucket).unwrap() += amount;
125 }
126
127 vendor_details.push(aging);
128 }
129
130 vendor_details.sort_by(|a, b| b.total_balance.cmp(&a.total_balance));
131
132 let total_ap_balance: Decimal = bucket_totals.values().sum();
133 let total_current = bucket_totals
134 .get(&APAgingBucket::Current)
135 .copied()
136 .unwrap_or_default();
137 let total_overdue = total_ap_balance - total_current;
138
139 Self {
140 company_code,
141 as_of_date,
142 vendor_details,
143 bucket_totals,
144 total_ap_balance,
145 total_current,
146 total_overdue,
147 generated_at: chrono::Utc::now(),
148 }
149 }
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct VendorAging {
155 pub vendor_id: String,
157 pub vendor_name: String,
159 pub total_balance: Decimal,
161 pub bucket_amounts: HashMap<APAgingBucket, Decimal>,
163 pub invoice_counts: HashMap<APAgingBucket, u32>,
165 pub oldest_invoice_date: Option<NaiveDate>,
167 pub weighted_avg_days: Decimal,
169}
170
171impl VendorAging {
172 pub fn from_invoices(
174 vendor_id: String,
175 vendor_name: String,
176 invoices: &[&APInvoice],
177 as_of_date: NaiveDate,
178 ) -> Self {
179 let mut bucket_amounts: HashMap<APAgingBucket, Decimal> = APAgingBucket::all()
180 .into_iter()
181 .map(|b| (b, Decimal::ZERO))
182 .collect();
183 let mut invoice_counts: HashMap<APAgingBucket, u32> =
184 APAgingBucket::all().into_iter().map(|b| (b, 0)).collect();
185
186 let mut total_days_weighted = Decimal::ZERO;
187 let mut total_balance = Decimal::ZERO;
188 let mut oldest_date: Option<NaiveDate> = None;
189
190 for invoice in invoices {
191 let days_overdue = invoice.days_overdue(as_of_date);
192 let bucket = APAgingBucket::from_days_overdue(days_overdue);
193 let amount = invoice.amount_remaining;
194
195 *bucket_amounts.get_mut(&bucket).unwrap() += amount;
196 *invoice_counts.get_mut(&bucket).unwrap() += 1;
197 total_balance += amount;
198
199 let days_outstanding = (as_of_date - invoice.invoice_date).num_days();
200 total_days_weighted += Decimal::from(days_outstanding) * amount;
201
202 if oldest_date.map_or(true, |d| invoice.invoice_date < d) {
203 oldest_date = Some(invoice.invoice_date);
204 }
205 }
206
207 let weighted_avg_days = if total_balance > Decimal::ZERO {
208 (total_days_weighted / total_balance).round_dp(1)
209 } else {
210 Decimal::ZERO
211 };
212
213 Self {
214 vendor_id,
215 vendor_name,
216 total_balance,
217 bucket_amounts,
218 invoice_counts,
219 oldest_invoice_date: oldest_date,
220 weighted_avg_days,
221 }
222 }
223}
224
225#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct APCashForecast {
228 pub company_code: String,
230 pub start_date: NaiveDate,
232 pub end_date: NaiveDate,
234 pub daily_forecast: Vec<DailyForecast>,
236 pub weekly_summary: Vec<WeeklyForecast>,
238 pub total_outflow: Decimal,
240 pub total_discount_opportunity: Decimal,
242 pub generated_at: chrono::DateTime<chrono::Utc>,
244}
245
246impl APCashForecast {
247 pub fn from_invoices(
249 company_code: String,
250 invoices: &[APInvoice],
251 start_date: NaiveDate,
252 end_date: NaiveDate,
253 include_discounts: bool,
254 ) -> Self {
255 let open_invoices: Vec<_> = invoices
256 .iter()
257 .filter(|i| {
258 i.company_code == company_code
259 && matches!(
260 i.status,
261 SubledgerDocumentStatus::Open | SubledgerDocumentStatus::PartiallyCleared
262 )
263 && i.due_date >= start_date
264 && i.due_date <= end_date
265 })
266 .collect();
267
268 let mut daily_map: HashMap<NaiveDate, DailyForecast> = HashMap::new();
270 let mut total_outflow = Decimal::ZERO;
271 let mut total_discount = Decimal::ZERO;
272
273 for invoice in open_invoices {
274 let amount = invoice.amount_remaining;
275 let discount = if include_discounts {
276 invoice.available_discount(start_date)
277 } else {
278 Decimal::ZERO
279 };
280
281 let entry = daily_map
282 .entry(invoice.due_date)
283 .or_insert_with(|| DailyForecast {
284 date: invoice.due_date,
285 amount_due: Decimal::ZERO,
286 invoice_count: 0,
287 discount_available: Decimal::ZERO,
288 vendor_count: 0,
289 vendors: Vec::new(),
290 });
291
292 entry.amount_due += amount;
293 entry.invoice_count += 1;
294 entry.discount_available += discount;
295 if !entry.vendors.contains(&invoice.vendor_id) {
296 entry.vendors.push(invoice.vendor_id.clone());
297 entry.vendor_count += 1;
298 }
299
300 total_outflow += amount;
301 total_discount += discount;
302 }
303
304 let mut daily_forecast: Vec<DailyForecast> = daily_map.into_values().collect();
306 daily_forecast.sort_by_key(|d| d.date);
307
308 let weekly_summary = Self::build_weekly_summary(&daily_forecast);
310
311 Self {
312 company_code,
313 start_date,
314 end_date,
315 daily_forecast,
316 weekly_summary,
317 total_outflow,
318 total_discount_opportunity: total_discount,
319 generated_at: chrono::Utc::now(),
320 }
321 }
322
323 fn build_weekly_summary(daily: &[DailyForecast]) -> Vec<WeeklyForecast> {
325 let mut weekly: HashMap<NaiveDate, WeeklyForecast> = HashMap::new();
326
327 for day in daily {
328 let weekday = day.date.weekday().num_days_from_monday();
330 let week_start = day.date - chrono::Duration::days(weekday as i64);
331
332 let entry = weekly.entry(week_start).or_insert_with(|| WeeklyForecast {
333 week_start,
334 week_end: week_start + chrono::Duration::days(6),
335 amount_due: Decimal::ZERO,
336 invoice_count: 0,
337 discount_available: Decimal::ZERO,
338 });
339
340 entry.amount_due += day.amount_due;
341 entry.invoice_count += day.invoice_count;
342 entry.discount_available += day.discount_available;
343 }
344
345 let mut result: Vec<WeeklyForecast> = weekly.into_values().collect();
346 result.sort_by_key(|w| w.week_start);
347 result
348 }
349}
350
351#[derive(Debug, Clone, Serialize, Deserialize)]
353pub struct DailyForecast {
354 pub date: NaiveDate,
356 pub amount_due: Decimal,
358 pub invoice_count: u32,
360 pub discount_available: Decimal,
362 pub vendor_count: u32,
364 pub vendors: Vec<String>,
366}
367
368#[derive(Debug, Clone, Serialize, Deserialize)]
370pub struct WeeklyForecast {
371 pub week_start: NaiveDate,
373 pub week_end: NaiveDate,
375 pub amount_due: Decimal,
377 pub invoice_count: u32,
379 pub discount_available: Decimal,
381}
382
383#[derive(Debug, Clone, Serialize, Deserialize)]
385pub struct DPOCalculation {
386 pub company_code: String,
388 pub period_start: NaiveDate,
390 pub period_end: NaiveDate,
392 pub average_ap: Decimal,
394 pub total_cogs: Decimal,
396 pub dpo_days: Decimal,
398 pub prior_period_dpo: Option<Decimal>,
400 pub dpo_change: Option<Decimal>,
402}
403
404impl DPOCalculation {
405 pub fn calculate(
407 company_code: String,
408 period_start: NaiveDate,
409 period_end: NaiveDate,
410 beginning_ap: Decimal,
411 ending_ap: Decimal,
412 total_cogs: Decimal,
413 ) -> Self {
414 let average_ap = (beginning_ap + ending_ap) / dec!(2);
415 let days_in_period = (period_end - period_start).num_days();
416
417 let dpo_days = if total_cogs > Decimal::ZERO {
418 (average_ap / total_cogs * Decimal::from(days_in_period)).round_dp(1)
419 } else {
420 Decimal::ZERO
421 };
422
423 Self {
424 company_code,
425 period_start,
426 period_end,
427 average_ap,
428 total_cogs,
429 dpo_days,
430 prior_period_dpo: None,
431 dpo_change: None,
432 }
433 }
434
435 pub fn with_prior_period(mut self, prior_dpo: Decimal) -> Self {
437 self.prior_period_dpo = Some(prior_dpo);
438 self.dpo_change = Some(self.dpo_days - prior_dpo);
439 self
440 }
441}
442
443#[derive(Debug, Clone, Serialize, Deserialize)]
445pub struct PaymentOptimization {
446 pub analysis_date: NaiveDate,
448 pub available_cash: Decimal,
450 pub recommended_payments: Vec<OptimizedPayment>,
452 pub total_payment: Decimal,
454 pub discount_captured: Decimal,
456 pub effective_discount_rate: Decimal,
458 pub deferred_invoices: Vec<DeferredInvoice>,
460}
461
462impl PaymentOptimization {
463 pub fn optimize(
465 invoices: &[APInvoice],
466 available_cash: Decimal,
467 analysis_date: NaiveDate,
468 company_code: &str,
469 ) -> Self {
470 let mut open_invoices: Vec<_> = invoices
471 .iter()
472 .filter(|i| {
473 i.company_code == company_code
474 && i.status == SubledgerDocumentStatus::Open
475 && i.is_payable()
476 })
477 .collect();
478
479 open_invoices.sort_by(|a, b| {
481 let a_discount_rate = if a.amount_remaining > Decimal::ZERO {
482 a.available_discount(analysis_date) / a.amount_remaining
483 } else {
484 Decimal::ZERO
485 };
486 let b_discount_rate = if b.amount_remaining > Decimal::ZERO {
487 b.available_discount(analysis_date) / b.amount_remaining
488 } else {
489 Decimal::ZERO
490 };
491 b_discount_rate.cmp(&a_discount_rate)
492 });
493
494 let mut remaining_cash = available_cash;
495 let mut recommended_payments = Vec::new();
496 let mut deferred_invoices = Vec::new();
497 let mut total_payment = Decimal::ZERO;
498 let mut discount_captured = Decimal::ZERO;
499
500 for invoice in open_invoices {
501 let discount = invoice.available_discount(analysis_date);
502 let payment_amount = invoice.amount_remaining - discount;
503
504 if payment_amount <= remaining_cash {
505 recommended_payments.push(OptimizedPayment {
506 vendor_id: invoice.vendor_id.clone(),
507 vendor_name: invoice.vendor_name.clone(),
508 invoice_number: invoice.invoice_number.clone(),
509 invoice_amount: invoice.amount_remaining,
510 payment_amount,
511 discount,
512 due_date: invoice.due_date,
513 priority: PaymentPriority::from_discount(discount, invoice.amount_remaining),
514 });
515
516 total_payment += payment_amount;
517 discount_captured += discount;
518 remaining_cash -= payment_amount;
519 } else {
520 deferred_invoices.push(DeferredInvoice {
521 vendor_id: invoice.vendor_id.clone(),
522 invoice_number: invoice.invoice_number.clone(),
523 amount: invoice.amount_remaining,
524 due_date: invoice.due_date,
525 discount_lost: discount,
526 });
527 }
528 }
529
530 let effective_discount_rate = if total_payment > Decimal::ZERO {
531 (discount_captured / (total_payment + discount_captured) * dec!(100)).round_dp(2)
532 } else {
533 Decimal::ZERO
534 };
535
536 Self {
537 analysis_date,
538 available_cash,
539 recommended_payments,
540 total_payment,
541 discount_captured,
542 effective_discount_rate,
543 deferred_invoices,
544 }
545 }
546}
547
548#[derive(Debug, Clone, Serialize, Deserialize)]
550pub struct OptimizedPayment {
551 pub vendor_id: String,
553 pub vendor_name: String,
555 pub invoice_number: String,
557 pub invoice_amount: Decimal,
559 pub payment_amount: Decimal,
561 pub discount: Decimal,
563 pub due_date: NaiveDate,
565 pub priority: PaymentPriority,
567}
568
569#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
571pub enum PaymentPriority {
572 High,
574 Medium,
576 Low,
578}
579
580impl PaymentPriority {
581 pub fn from_discount(discount: Decimal, amount: Decimal) -> Self {
583 if amount <= Decimal::ZERO {
584 return PaymentPriority::Low;
585 }
586 let rate = discount / amount * dec!(100);
587 if rate >= dec!(2) {
588 PaymentPriority::High
589 } else if rate >= dec!(1) {
590 PaymentPriority::Medium
591 } else {
592 PaymentPriority::Low
593 }
594 }
595}
596
597#[derive(Debug, Clone, Serialize, Deserialize)]
599pub struct DeferredInvoice {
600 pub vendor_id: String,
602 pub invoice_number: String,
604 pub amount: Decimal,
606 pub due_date: NaiveDate,
608 pub discount_lost: Decimal,
610}
611
612#[cfg(test)]
613mod tests {
614 use super::*;
615 use crate::models::subledger::PaymentTerms;
616
617 fn create_test_invoices() -> Vec<APInvoice> {
618 vec![
619 {
620 let mut inv = APInvoice::new(
621 "AP001".to_string(),
622 "V001".to_string(),
623 "1000".to_string(),
624 "VEND001".to_string(),
625 "Vendor A".to_string(),
626 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
627 PaymentTerms::two_ten_net_30(),
628 "USD".to_string(),
629 );
630 inv.amount_remaining = dec!(1000);
631 inv
632 },
633 {
634 let mut inv = APInvoice::new(
635 "AP002".to_string(),
636 "V002".to_string(),
637 "1000".to_string(),
638 "VEND001".to_string(),
639 "Vendor A".to_string(),
640 NaiveDate::from_ymd_opt(2023, 12, 1).unwrap(),
641 PaymentTerms::net_30(),
642 "USD".to_string(),
643 );
644 inv.amount_remaining = dec!(500);
645 inv
646 },
647 ]
648 }
649
650 #[test]
651 fn test_ap_aging_report() {
652 let invoices = create_test_invoices();
653 let as_of_date = NaiveDate::from_ymd_opt(2024, 2, 15).unwrap();
654
655 let report = APAgingReport::from_invoices("1000".to_string(), &invoices, as_of_date);
656
657 assert_eq!(report.total_ap_balance, dec!(1500));
658 }
659
660 #[test]
661 fn test_cash_forecast() {
662 let invoices = create_test_invoices();
663 let start_date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
664 let end_date = NaiveDate::from_ymd_opt(2024, 3, 31).unwrap();
665
666 let forecast = APCashForecast::from_invoices(
667 "1000".to_string(),
668 &invoices,
669 start_date,
670 end_date,
671 true,
672 );
673
674 assert!(forecast.total_outflow > Decimal::ZERO);
675 }
676
677 #[test]
678 fn test_dpo_calculation() {
679 let dpo = DPOCalculation::calculate(
680 "1000".to_string(),
681 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
682 NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(),
683 dec!(50_000),
684 dec!(60_000),
685 dec!(300_000),
686 );
687
688 assert!(dpo.dpo_days > Decimal::ZERO);
691 }
692}