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
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/// Aging detail for a single vendor.
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct VendorAging {
157    /// Vendor ID.
158    pub vendor_id: String,
159    /// Vendor name.
160    pub vendor_name: String,
161    /// Total balance.
162    pub total_balance: Decimal,
163    /// Amounts by bucket.
164    pub bucket_amounts: HashMap<APAgingBucket, Decimal>,
165    /// Invoice count by bucket.
166    pub invoice_counts: HashMap<APAgingBucket, u32>,
167    /// Oldest invoice date.
168    pub oldest_invoice_date: Option<NaiveDate>,
169    /// Weighted average days outstanding.
170    pub weighted_avg_days: Decimal,
171}
172
173impl VendorAging {
174    /// Creates vendor aging from invoices.
175    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/// Cash flow forecast for AP.
232#[derive(Debug, Clone, Serialize, Deserialize)]
233pub struct APCashForecast {
234    /// Company code.
235    pub company_code: String,
236    /// Forecast start date.
237    pub start_date: NaiveDate,
238    /// Forecast end date.
239    pub end_date: NaiveDate,
240    /// Daily forecast.
241    pub daily_forecast: Vec<DailyForecast>,
242    /// Weekly summary.
243    pub weekly_summary: Vec<WeeklyForecast>,
244    /// Total forecasted outflow.
245    pub total_outflow: Decimal,
246    /// Total discount opportunity.
247    pub total_discount_opportunity: Decimal,
248    /// Generated timestamp.
249    pub generated_at: chrono::DateTime<chrono::Utc>,
250}
251
252impl APCashForecast {
253    /// Creates a cash forecast from invoices.
254    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        // Build daily forecast
275        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        // Convert to sorted vector
311        let mut daily_forecast: Vec<DailyForecast> = daily_map.into_values().collect();
312        daily_forecast.sort_by_key(|d| d.date);
313
314        // Build weekly summary
315        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    /// Builds weekly summary from daily forecast.
330    fn build_weekly_summary(daily: &[DailyForecast]) -> Vec<WeeklyForecast> {
331        let mut weekly: HashMap<NaiveDate, WeeklyForecast> = HashMap::new();
332
333        for day in daily {
334            // Get Monday of the week
335            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/// Daily forecast entry.
358#[derive(Debug, Clone, Serialize, Deserialize)]
359pub struct DailyForecast {
360    /// Date.
361    pub date: NaiveDate,
362    /// Amount due.
363    pub amount_due: Decimal,
364    /// Invoice count.
365    pub invoice_count: u32,
366    /// Discount available if paid today.
367    pub discount_available: Decimal,
368    /// Number of vendors.
369    pub vendor_count: u32,
370    /// Vendor IDs.
371    pub vendors: Vec<String>,
372}
373
374/// Weekly forecast summary.
375#[derive(Debug, Clone, Serialize, Deserialize)]
376pub struct WeeklyForecast {
377    /// Week start date (Monday).
378    pub week_start: NaiveDate,
379    /// Week end date (Sunday).
380    pub week_end: NaiveDate,
381    /// Amount due.
382    pub amount_due: Decimal,
383    /// Invoice count.
384    pub invoice_count: u32,
385    /// Discount available.
386    pub discount_available: Decimal,
387}
388
389/// DPO (Days Payable Outstanding) calculation.
390#[derive(Debug, Clone, Serialize, Deserialize)]
391pub struct DPOCalculation {
392    /// Company code.
393    pub company_code: String,
394    /// Calculation period start.
395    pub period_start: NaiveDate,
396    /// Calculation period end.
397    pub period_end: NaiveDate,
398    /// Average AP balance.
399    pub average_ap: Decimal,
400    /// Total COGS/purchases for period.
401    pub total_cogs: Decimal,
402    /// DPO result.
403    pub dpo_days: Decimal,
404    /// Prior period DPO for comparison.
405    pub prior_period_dpo: Option<Decimal>,
406    /// DPO change.
407    pub dpo_change: Option<Decimal>,
408}
409
410impl DPOCalculation {
411    /// Calculates DPO.
412    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    /// Sets prior period comparison.
442    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/// Payment optimization result.
450#[derive(Debug, Clone, Serialize, Deserialize)]
451pub struct PaymentOptimization {
452    /// Analysis date.
453    pub analysis_date: NaiveDate,
454    /// Available cash for payments.
455    pub available_cash: Decimal,
456    /// Recommended payments.
457    pub recommended_payments: Vec<OptimizedPayment>,
458    /// Total payment amount.
459    pub total_payment: Decimal,
460    /// Total discount captured.
461    pub discount_captured: Decimal,
462    /// Effective discount rate.
463    pub effective_discount_rate: Decimal,
464    /// Unpaid invoices.
465    pub deferred_invoices: Vec<DeferredInvoice>,
466}
467
468impl PaymentOptimization {
469    /// Optimizes payments to maximize discount capture.
470    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        // Sort by discount opportunity (highest discount rate first)
486        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/// An optimized payment recommendation.
555#[derive(Debug, Clone, Serialize, Deserialize)]
556pub struct OptimizedPayment {
557    /// Vendor ID.
558    pub vendor_id: String,
559    /// Vendor name.
560    pub vendor_name: String,
561    /// Invoice number.
562    pub invoice_number: String,
563    /// Original invoice amount.
564    pub invoice_amount: Decimal,
565    /// Recommended payment amount.
566    pub payment_amount: Decimal,
567    /// Discount to capture.
568    pub discount: Decimal,
569    /// Due date.
570    pub due_date: NaiveDate,
571    /// Payment priority.
572    pub priority: PaymentPriority,
573}
574
575/// Payment priority level.
576#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
577pub enum PaymentPriority {
578    /// High priority (high discount available).
579    High,
580    /// Medium priority.
581    Medium,
582    /// Low priority.
583    Low,
584}
585
586impl PaymentPriority {
587    /// Determines priority from discount percentage.
588    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/// An invoice deferred for later payment.
604#[derive(Debug, Clone, Serialize, Deserialize)]
605pub struct DeferredInvoice {
606    /// Vendor ID.
607    pub vendor_id: String,
608    /// Invoice number.
609    pub invoice_number: String,
610    /// Amount.
611    pub amount: Decimal,
612    /// Due date.
613    pub due_date: NaiveDate,
614    /// Discount that will be lost.
615    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        // Average AP = 55,000, COGS = 300,000, Days = 31
696        // DPO = (55,000 / 300,000) * 31 = 5.68
697        assert!(dpo.dpo_days > Decimal::ZERO);
698    }
699}