Skip to main content

datasynth_core/models/subledger/ap/
schedule.rs

1//! AP Payment Schedule and Forecasting models.
2
3use 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/// AP Aging bucket (similar to AR but for payables).
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
14pub enum APAgingBucket {
15    /// Not yet due.
16    Current,
17    /// 1-30 days overdue.
18    Days1To30,
19    /// 31-60 days overdue.
20    Days31To60,
21    /// 61-90 days overdue.
22    Days61To90,
23    /// Over 90 days overdue.
24    Over90Days,
25}
26
27impl APAgingBucket {
28    /// Gets all buckets in order.
29    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    /// Gets bucket name.
40    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    /// Determines bucket from days overdue.
51    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/// AP Aging report.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct APAgingReport {
69    /// Company code.
70    pub company_code: String,
71    /// As-of date.
72    pub as_of_date: NaiveDate,
73    /// Vendor aging details.
74    pub vendor_details: Vec<VendorAging>,
75    /// Summary by bucket.
76    pub bucket_totals: HashMap<APAgingBucket, Decimal>,
77    /// Total AP balance.
78    pub total_ap_balance: Decimal,
79    /// Total current.
80    pub total_current: Decimal,
81    /// Total overdue.
82    pub total_overdue: Decimal,
83    /// Generated timestamp.
84    pub generated_at: chrono::DateTime<chrono::Utc>,
85}
86
87impl APAgingReport {
88    /// Creates an aging report from invoices.
89    pub fn from_invoices(
90        company_code: String,
91        invoices: &[APInvoice],
92        as_of_date: NaiveDate,
93    ) -> Self {
94        // Group by vendor
95        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/// Aging detail for a single vendor.
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct VendorAging {
155    /// Vendor ID.
156    pub vendor_id: String,
157    /// Vendor name.
158    pub vendor_name: String,
159    /// Total balance.
160    pub total_balance: Decimal,
161    /// Amounts by bucket.
162    pub bucket_amounts: HashMap<APAgingBucket, Decimal>,
163    /// Invoice count by bucket.
164    pub invoice_counts: HashMap<APAgingBucket, u32>,
165    /// Oldest invoice date.
166    pub oldest_invoice_date: Option<NaiveDate>,
167    /// Weighted average days outstanding.
168    pub weighted_avg_days: Decimal,
169}
170
171impl VendorAging {
172    /// Creates vendor aging from invoices.
173    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/// Cash flow forecast for AP.
226#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct APCashForecast {
228    /// Company code.
229    pub company_code: String,
230    /// Forecast start date.
231    pub start_date: NaiveDate,
232    /// Forecast end date.
233    pub end_date: NaiveDate,
234    /// Daily forecast.
235    pub daily_forecast: Vec<DailyForecast>,
236    /// Weekly summary.
237    pub weekly_summary: Vec<WeeklyForecast>,
238    /// Total forecasted outflow.
239    pub total_outflow: Decimal,
240    /// Total discount opportunity.
241    pub total_discount_opportunity: Decimal,
242    /// Generated timestamp.
243    pub generated_at: chrono::DateTime<chrono::Utc>,
244}
245
246impl APCashForecast {
247    /// Creates a cash forecast from invoices.
248    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        // Build daily forecast
269        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        // Convert to sorted vector
305        let mut daily_forecast: Vec<DailyForecast> = daily_map.into_values().collect();
306        daily_forecast.sort_by_key(|d| d.date);
307
308        // Build weekly summary
309        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    /// Builds weekly summary from daily forecast.
324    fn build_weekly_summary(daily: &[DailyForecast]) -> Vec<WeeklyForecast> {
325        let mut weekly: HashMap<NaiveDate, WeeklyForecast> = HashMap::new();
326
327        for day in daily {
328            // Get Monday of the week
329            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/// Daily forecast entry.
352#[derive(Debug, Clone, Serialize, Deserialize)]
353pub struct DailyForecast {
354    /// Date.
355    pub date: NaiveDate,
356    /// Amount due.
357    pub amount_due: Decimal,
358    /// Invoice count.
359    pub invoice_count: u32,
360    /// Discount available if paid today.
361    pub discount_available: Decimal,
362    /// Number of vendors.
363    pub vendor_count: u32,
364    /// Vendor IDs.
365    pub vendors: Vec<String>,
366}
367
368/// Weekly forecast summary.
369#[derive(Debug, Clone, Serialize, Deserialize)]
370pub struct WeeklyForecast {
371    /// Week start date (Monday).
372    pub week_start: NaiveDate,
373    /// Week end date (Sunday).
374    pub week_end: NaiveDate,
375    /// Amount due.
376    pub amount_due: Decimal,
377    /// Invoice count.
378    pub invoice_count: u32,
379    /// Discount available.
380    pub discount_available: Decimal,
381}
382
383/// DPO (Days Payable Outstanding) calculation.
384#[derive(Debug, Clone, Serialize, Deserialize)]
385pub struct DPOCalculation {
386    /// Company code.
387    pub company_code: String,
388    /// Calculation period start.
389    pub period_start: NaiveDate,
390    /// Calculation period end.
391    pub period_end: NaiveDate,
392    /// Average AP balance.
393    pub average_ap: Decimal,
394    /// Total COGS/purchases for period.
395    pub total_cogs: Decimal,
396    /// DPO result.
397    pub dpo_days: Decimal,
398    /// Prior period DPO for comparison.
399    pub prior_period_dpo: Option<Decimal>,
400    /// DPO change.
401    pub dpo_change: Option<Decimal>,
402}
403
404impl DPOCalculation {
405    /// Calculates DPO.
406    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    /// Sets prior period comparison.
436    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/// Payment optimization result.
444#[derive(Debug, Clone, Serialize, Deserialize)]
445pub struct PaymentOptimization {
446    /// Analysis date.
447    pub analysis_date: NaiveDate,
448    /// Available cash for payments.
449    pub available_cash: Decimal,
450    /// Recommended payments.
451    pub recommended_payments: Vec<OptimizedPayment>,
452    /// Total payment amount.
453    pub total_payment: Decimal,
454    /// Total discount captured.
455    pub discount_captured: Decimal,
456    /// Effective discount rate.
457    pub effective_discount_rate: Decimal,
458    /// Unpaid invoices.
459    pub deferred_invoices: Vec<DeferredInvoice>,
460}
461
462impl PaymentOptimization {
463    /// Optimizes payments to maximize discount capture.
464    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        // Sort by discount opportunity (highest discount rate first)
480        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/// An optimized payment recommendation.
549#[derive(Debug, Clone, Serialize, Deserialize)]
550pub struct OptimizedPayment {
551    /// Vendor ID.
552    pub vendor_id: String,
553    /// Vendor name.
554    pub vendor_name: String,
555    /// Invoice number.
556    pub invoice_number: String,
557    /// Original invoice amount.
558    pub invoice_amount: Decimal,
559    /// Recommended payment amount.
560    pub payment_amount: Decimal,
561    /// Discount to capture.
562    pub discount: Decimal,
563    /// Due date.
564    pub due_date: NaiveDate,
565    /// Payment priority.
566    pub priority: PaymentPriority,
567}
568
569/// Payment priority level.
570#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
571pub enum PaymentPriority {
572    /// High priority (high discount available).
573    High,
574    /// Medium priority.
575    Medium,
576    /// Low priority.
577    Low,
578}
579
580impl PaymentPriority {
581    /// Determines priority from discount percentage.
582    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/// An invoice deferred for later payment.
598#[derive(Debug, Clone, Serialize, Deserialize)]
599pub struct DeferredInvoice {
600    /// Vendor ID.
601    pub vendor_id: String,
602    /// Invoice number.
603    pub invoice_number: String,
604    /// Amount.
605    pub amount: Decimal,
606    /// Due date.
607    pub due_date: NaiveDate,
608    /// Discount that will be lost.
609    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        // Average AP = 55,000, COGS = 300,000, Days = 31
689        // DPO = (55,000 / 300,000) * 31 = 5.68
690        assert!(dpo.dpo_days > Decimal::ZERO);
691    }
692}