Skip to main content

finance_solution/amortization/
mod.rs

1//! Amortization schedules with the same **solution / series / table** pattern as payment TVM.
2//!
3//! # What you get
4//!
5//! | Layer | API | Purpose |
6//! |-------|-----|---------|
7//! | Scalar (Excel) | [`ipmt`], [`ppmt`], [`cumipmt`], [`cumprinc`] | Single numbers, spreadsheet parity |
8//! | Solution | [`amortization_solution`] | Inputs + payment + formulas |
9//! | Series | [`AmortizationSolution::series`] | Period-by-period principal / interest |
10//! | Table | [`AmortizationSeries::print_table`] | Pretty terminal / copy-paste output |
11//!
12//! # Error handling (v0.1+)
13//!
14//! Public construction and Excel-style scalars return [`FinanceResult`](crate::FinanceResult).
15//! Invalid rates, zero periods, and out-of-range period indices are **errors**, not panics.
16//! [`AmortizationSolution::series`] is infallible once a solution was constructed successfully.
17//!
18//! Period numbers are **1-based**, matching Excel / Google Sheets.
19//! Sign conventions match [`payment`](crate::payment).
20//! Timing uses [`PaymentTiming`](crate::PaymentTiming) (or `bool` via [`From`]).
21//!
22//! # Example: solution → series → table
23//!
24//! ```
25//! use finance_solution::*;
26//!
27//! // $10,000 at 8% APR, monthly, 12 months
28//! let rate = 0.08 / 12.0;
29//! let periods = 12;
30//! let principal = 10_000.0;
31//!
32//! let solution = amortization_solution(rate, periods, principal, 0.0, false).unwrap();
33//! assert!(solution.payment() < 0.0); // positive principal → negative payment
34//!
35//! let series = solution.series();
36//! assert_eq!(series.len(), periods as usize);
37//!
38//! // Pretty table (running totals + remaining amounts).
39//! series.print_table(true, true);
40//!
41//! assert_approx_equal!(solution.ipmt(1).unwrap(), series[0].interest());
42//! assert_approx_equal!(solution.ppmt(1).unwrap(), series[0].principal());
43//! ```
44//!
45//! `print_table(true, true)` prints a schedule like this (truncated for width in some
46//! terminals; values match a 12-month $10k loan at 8% APR monthly):
47//!
48//! ```text
49//! period    payment  principal  interest     balance  principal_to_date  interest_to_date  payments_to_date  principal_remaining  interest_remaining  payments_remaining
50//! ------  ---------  ---------  --------  ----------  -----------------  ----------------  ----------------  -------------------  ------------------  ------------------
51//!      1  -869.8843  -803.2176  -66.6667  9_196.7824          -803.2176          -66.6667         -869.8843          -9_196.7824           -371.9448         -9_568.7272
52//!      2  -869.8843  -808.5724  -61.3119  8_388.2100        -1_611.7900         -127.9785       -1_739.7686          -8_388.2100           -310.6329         -8_698.8429
53//!      3  -869.8843  -813.9629  -55.9214  7_574.2471        -2_425.7529         -183.8999       -2_609.6529          -7_574.2471           -254.7115         -7_828.9586
54//!    ...
55//!     12  -869.8843  -864.1235   -5.7608     -0.0000       -10_000.0000         -438.6115      -10_438.6115               0.0000             -0.0000              0.0000
56//! ```
57//!
58//! Period 1: payment ≈ −869.88 splits into interest ≈ −66.67 and principal ≈ −803.22;
59//! balance after payment ≈ 9,196.78. Period 12 pays the loan down to ≈ 0.
60use std::ops::Deref;
61
62use crate::util::error::{require_finite, require_rate_gt_minus_one, FinanceError, FinanceResult};
63use crate::{columns_with_strings, print_table_locale_opt};
64
65// ---------------------------------------------------------------------------
66// Solution
67// ---------------------------------------------------------------------------
68
69/// Full amortization setup: inputs, level payment, formulas, and access to a period series.
70///
71/// Create with [`amortization_solution`] (returns [`FinanceResult`]).
72///
73/// # Examples
74/// ```
75/// use finance_solution::*;
76///
77/// let s = amortization_solution(0.08 / 12.0, 12, 10_000.0, 0.0, false).unwrap();
78/// println!("{}", s.formula());
79/// println!("{}", s.symbolic_formula());
80/// assert_eq!(s.periods(), 12);
81/// s.print_table();
82/// ```
83///
84/// `print_table()` is sugar for `series().print_table(true, true)` and looks like:
85///
86/// ```text
87/// period    payment  principal  interest     balance  ...  payments_remaining
88/// ------  ---------  ---------  --------  ----------  ...  ------------------
89///      1  -869.8843  -803.2176  -66.6667  9_196.7824  ...         -9_568.7272
90///      2  -869.8843  -808.5724  -61.3119  8_388.2100  ...         -8_698.8429
91///    ...
92///     12  -869.8843  -864.1235   -5.7608     -0.0000  ...              0.0000
93/// ```
94#[derive(Clone, Debug)]
95pub struct AmortizationSolution {
96    rate: f64,
97    periods: u32,
98    present_value: f64,
99    future_value: f64,
100    due_at_beginning: bool,
101    payment: f64,
102    sum_of_payments: f64,
103    sum_of_interest: f64,
104    formula: String,
105    symbolic_formula: String,
106}
107
108impl AmortizationSolution {
109    pub(crate) fn new(
110        rate: f64,
111        periods: u32,
112        present_value: f64,
113        future_value: f64,
114        due_at_beginning: bool,
115        payment: f64,
116        formula: String,
117        symbolic_formula: String,
118    ) -> Self {
119        let sum_of_payments = payment * periods as f64;
120        let sum_of_interest = sum_of_payments + present_value + future_value;
121        Self {
122            rate,
123            periods,
124            present_value,
125            future_value,
126            due_at_beginning,
127            payment,
128            sum_of_payments,
129            sum_of_interest,
130            formula,
131            symbolic_formula,
132        }
133    }
134
135    /// Periodic rate.
136    pub fn rate(&self) -> f64 {
137        self.rate
138    }
139
140    /// Total number of periods.
141    pub fn periods(&self) -> u32 {
142        self.periods
143    }
144
145    /// Present value (principal).
146    pub fn present_value(&self) -> f64 {
147        self.present_value
148    }
149
150    /// Future value (balloon / residual).
151    pub fn future_value(&self) -> f64 {
152        self.future_value
153    }
154
155    /// Whether payment is due at the beginning of each period.
156    pub fn due_at_beginning(&self) -> bool {
157        self.due_at_beginning
158    }
159
160    /// Level payment each period (Excel `PMT`).
161    pub fn payment(&self) -> f64 {
162        self.payment
163    }
164
165    /// `payment * periods`.
166    pub fn sum_of_payments(&self) -> f64 {
167        self.sum_of_payments
168    }
169
170    /// Total interest over the full term (`sum_of_payments + present_value + future_value`).
171    pub fn sum_of_interest(&self) -> f64 {
172        self.sum_of_interest
173    }
174
175    /// Concrete formula string with substituted values.
176    pub fn formula(&self) -> &str {
177        &self.formula
178    }
179
180    /// Symbolic formula (e.g. `pmt = ...`).
181    pub fn symbolic_formula(&self) -> &str {
182        &self.symbolic_formula
183    }
184
185    /// Interest portion for a 1-based period (Excel `IPMT`).
186    ///
187    /// # Errors
188    /// [`FinanceError::InvalidPeriod`] if `period` is not in `1..=periods`.
189    pub fn ipmt(&self, period: u32) -> FinanceResult<f64> {
190        self.period_at(period).map(|p| p.interest())
191    }
192
193    /// Principal portion for a 1-based period (Excel `PPMT`).
194    ///
195    /// # Errors
196    /// [`FinanceError::InvalidPeriod`] if `period` is not in `1..=periods`.
197    pub fn ppmt(&self, period: u32) -> FinanceResult<f64> {
198        self.period_at(period).map(|p| p.principal())
199    }
200
201    /// Cumulative interest from `start_period`..=`end_period` (1-based, inclusive).
202    ///
203    /// # Errors
204    /// [`FinanceError::InvalidPeriod`] if the range is empty, inverted, or outside `1..=periods`.
205    pub fn cumipmt(&self, start_period: u32, end_period: u32) -> FinanceResult<f64> {
206        validate_range(start_period, end_period, self.periods)?;
207        let series = self.series();
208        let mut total = 0.0;
209        for p in start_period..=end_period {
210            total += series[(p - 1) as usize].interest();
211        }
212        Ok(total)
213    }
214
215    /// Cumulative principal from `start_period`..=`end_period` (1-based, inclusive).
216    ///
217    /// # Errors
218    /// [`FinanceError::InvalidPeriod`] if the range is empty, inverted, or outside `1..=periods`.
219    pub fn cumprinc(&self, start_period: u32, end_period: u32) -> FinanceResult<f64> {
220        validate_range(start_period, end_period, self.periods)?;
221        let series = self.series();
222        let mut total = 0.0;
223        for p in start_period..=end_period {
224            total += series[(p - 1) as usize].principal();
225        }
226        Ok(total)
227    }
228
229    fn period_at(&self, period: u32) -> FinanceResult<AmortizationPeriod> {
230        if period == 0 || period > self.periods {
231            return Err(FinanceError::InvalidPeriod {
232                period,
233                periods: self.periods,
234                message: "period must be in 1..=periods",
235            });
236        }
237        Ok(self.series()[(period - 1) as usize].clone())
238    }
239
240    /// Period-by-period amortization schedule.
241    ///
242    /// # Examples
243    /// ```
244    /// use finance_solution::*;
245    ///
246    /// let series = amortization_solution(0.08 / 12.0, 12, 10_000.0, 0.0, false)
247    ///     .unwrap()
248    ///     .series();
249    /// for row in series.iter() {
250    ///     assert_approx_equal!(row.principal() + row.interest(), row.payment());
251    /// }
252    /// series.print_table(true, true);
253    /// ```
254    ///
255    /// Example terminal output (first/last rows; full 12-period table in the module docs):
256    ///
257    /// ```text
258    /// period    payment  principal  interest     balance  principal_to_date  ...
259    /// ------  ---------  ---------  --------  ----------  -----------------  ...
260    ///      1  -869.8843  -803.2176  -66.6667  9_196.7824          -803.2176  ...
261    ///    ...
262    ///     12  -869.8843  -864.1235   -5.7608     -0.0000       -10_000.0000  ...
263    /// ```
264    pub fn series(&self) -> AmortizationSeries {
265        build_series(
266            self.rate,
267            self.periods,
268            self.present_value,
269            self.future_value,
270            self.due_at_beginning,
271            self.payment,
272            self.sum_of_payments,
273            self.sum_of_interest,
274        )
275    }
276
277    /// Print the full schedule with running totals and remaining amounts.
278    pub fn print_table(&self) {
279        self.series().print_table(true, true);
280    }
281
282    /// Locale-aware table (thousands separators, decimal places).
283    pub fn print_table_locale(&self, locale: &num_format::Locale, precision: usize) {
284        self.series()
285            .print_table_locale(true, true, locale, precision);
286    }
287}
288
289// ---------------------------------------------------------------------------
290// Series & period
291// ---------------------------------------------------------------------------
292
293/// Period-by-period amortization rows. Derefs to `[AmortizationPeriod]`.
294#[derive(Clone, Debug)]
295pub struct AmortizationSeries(Vec<AmortizationPeriod>);
296
297impl AmortizationSeries {
298    pub(crate) fn new(rows: Vec<AmortizationPeriod>) -> Self {
299        Self(rows)
300    }
301
302    /// Filter periods while preserving the series type (e.g. year-end months only).
303    ///
304    /// # Examples
305    /// ```
306    /// use finance_solution::*;
307    ///
308    /// let filtered = amortization_solution(0.01, 24, 8_000.0, 0.0, false)
309    ///     .unwrap()
310    ///     .series()
311    ///     .filter(|row| row.period() % 12 == 0);
312    /// assert_eq!(filtered.len(), 2);
313    /// filtered.print_table(true, false);
314    /// ```
315    pub fn filter<P>(&self, predicate: P) -> Self
316    where
317        P: Fn(&&AmortizationPeriod) -> bool,
318    {
319        Self(self.iter().filter(|x| predicate(x)).cloned().collect())
320    }
321
322    /// Pretty-print the schedule to stdout.
323    ///
324    /// * `include_running_totals` – payments/principal/interest to date  
325    /// * `include_remaining_amounts` – remaining principal / interest / payments
326    ///
327    /// # Example output
328    ///
329    /// For `amortization_solution(0.08/12, 12, 10_000, 0, false).series().print_table(true, true)`:
330    ///
331    /// ```text
332    /// period    payment  principal  interest     balance  principal_to_date  interest_to_date  payments_to_date  principal_remaining  interest_remaining  payments_remaining
333    /// ------  ---------  ---------  --------  ----------  -----------------  ----------------  ----------------  -------------------  ------------------  ------------------
334    ///      1  -869.8843  -803.2176  -66.6667  9_196.7824          -803.2176          -66.6667         -869.8843          -9_196.7824           -371.9448         -9_568.7272
335    ///      2  -869.8843  -808.5724  -61.3119  8_388.2100        -1_611.7900         -127.9785       -1_739.7686          -8_388.2100           -310.6329         -8_698.8429
336    ///      3  -869.8843  -813.9629  -55.9214  7_574.2471        -2_425.7529         -183.8999       -2_609.6529          -7_574.2471           -254.7115         -7_828.9586
337    ///      4  -869.8843  -819.3893  -50.4950  6_754.8578        -3_245.1422         -234.3949       -3_479.5372          -6_754.8578           -204.2166         -6_959.0743
338    ///      5  -869.8843  -824.8519  -45.0324  5_930.0059        -4_069.9941         -279.4273       -4_349.4215          -5_930.0059           -159.1842         -6_089.1900
339    ///      6  -869.8843  -830.3509  -39.5334  5_099.6549        -4_900.3451         -318.9607       -5_219.3057          -5_099.6549           -119.6508         -5_219.3057
340    ///      7  -869.8843  -835.8866  -33.9977  4_263.7684        -5_736.2316         -352.9584       -6_089.1900          -4_263.7684            -85.6531         -4_349.4215
341    ///      8  -869.8843  -841.4592  -28.4251  3_422.3092        -6_577.6908         -381.3835       -6_959.0743          -3_422.3092            -57.2280         -3_479.5372
342    ///      9  -869.8843  -847.0689  -22.8154  2_575.2403        -7_424.7597         -404.1989       -7_828.9586          -2_575.2403            -34.4126         -2_609.6529
343    ///     10  -869.8843  -852.7160  -17.1683  1_722.5243        -8_277.4757         -421.3672       -8_698.8429          -1_722.5243            -17.2443         -1_739.7686
344    ///     11  -869.8843  -858.4008  -11.4835    864.1235        -9_135.8765         -432.8507       -9_568.7272            -864.1235             -5.7608           -869.8843
345    ///     12  -869.8843  -864.1235   -5.7608     -0.0000       -10_000.0000         -438.6115      -10_438.6115               0.0000             -0.0000              0.0000
346    /// ```
347    pub fn print_table(&self, include_running_totals: bool, include_remaining_amounts: bool) {
348        self.print_table_locale_opt(
349            include_running_totals,
350            include_remaining_amounts,
351            None,
352            None,
353        );
354    }
355
356    /// Locale-aware pretty-print.
357    pub fn print_table_locale(
358        &self,
359        include_running_totals: bool,
360        include_remaining_amounts: bool,
361        locale: &num_format::Locale,
362        precision: usize,
363    ) {
364        self.print_table_locale_opt(
365            include_running_totals,
366            include_remaining_amounts,
367            Some(locale),
368            Some(precision),
369        );
370    }
371
372    fn print_table_locale_opt(
373        &self,
374        include_running_totals: bool,
375        include_remaining_amounts: bool,
376        locale: Option<&num_format::Locale>,
377        precision: Option<usize>,
378    ) {
379        let columns = columns_with_strings(&[
380            ("period", "i", true),
381            ("payment", "f", true),
382            ("principal", "f", true),
383            ("interest", "f", true),
384            ("balance", "f", true),
385            ("principal_to_date", "f", include_running_totals),
386            ("interest_to_date", "f", include_running_totals),
387            ("payments_to_date", "f", include_running_totals),
388            ("principal_remaining", "f", include_remaining_amounts),
389            ("interest_remaining", "f", include_remaining_amounts),
390            ("payments_remaining", "f", include_remaining_amounts),
391        ]);
392        let data = self
393            .iter()
394            .map(|e| {
395                vec![
396                    e.period.to_string(),
397                    e.payment.to_string(),
398                    e.principal.to_string(),
399                    e.interest.to_string(),
400                    e.balance.to_string(),
401                    e.principal_to_date.to_string(),
402                    e.interest_to_date.to_string(),
403                    e.payments_to_date.to_string(),
404                    e.principal_remaining.to_string(),
405                    e.interest_remaining.to_string(),
406                    e.payments_remaining.to_string(),
407                ]
408            })
409            .collect::<Vec<_>>();
410        print_table_locale_opt(&columns, data, locale, precision);
411    }
412}
413
414impl Deref for AmortizationSeries {
415    type Target = Vec<AmortizationPeriod>;
416
417    fn deref(&self) -> &Self::Target {
418        &self.0
419    }
420}
421
422/// One period of an amortization schedule.
423#[derive(Clone, Debug)]
424pub struct AmortizationPeriod {
425    period: u32,
426    rate: f64,
427    payment: f64,
428    principal: f64,
429    interest: f64,
430    principal_to_date: f64,
431    interest_to_date: f64,
432    principal_remaining: f64,
433    interest_remaining: f64,
434    payments_to_date: f64,
435    payments_remaining: f64,
436    /// Outstanding principal balance **after** this period's payment.
437    balance: f64,
438    formula: String,
439    symbolic_formula: String,
440}
441
442impl AmortizationPeriod {
443    pub fn period(&self) -> u32 {
444        self.period
445    }
446    pub fn rate(&self) -> f64 {
447        self.rate
448    }
449    pub fn payment(&self) -> f64 {
450        self.payment
451    }
452    pub fn principal(&self) -> f64 {
453        self.principal
454    }
455    pub fn interest(&self) -> f64 {
456        self.interest
457    }
458    pub fn principal_to_date(&self) -> f64 {
459        self.principal_to_date
460    }
461    pub fn interest_to_date(&self) -> f64 {
462        self.interest_to_date
463    }
464    pub fn principal_remaining(&self) -> f64 {
465        self.principal_remaining
466    }
467    pub fn interest_remaining(&self) -> f64 {
468        self.interest_remaining
469    }
470    pub fn payments_to_date(&self) -> f64 {
471        self.payments_to_date
472    }
473    pub fn payments_remaining(&self) -> f64 {
474        self.payments_remaining
475    }
476    pub fn balance(&self) -> f64 {
477        self.balance
478    }
479    pub fn formula(&self) -> &str {
480        &self.formula
481    }
482    pub fn symbolic_formula(&self) -> &str {
483        &self.symbolic_formula
484    }
485}
486
487// ---------------------------------------------------------------------------
488// Constructors
489// ---------------------------------------------------------------------------
490
491/// Build an amortization solution (payment + schedule access).
492///
493/// Prefer this when you want formulas and tables, not only a single `f64`.
494/// Timing accepts [`PaymentTiming`] or `bool` (`false` = end of period).
495///
496/// # Errors
497/// Returns [`FinanceError`] for invalid rates, non-finite amounts, zero periods,
498/// or payment calculation failures (same domain as [`crate::payment`]).
499///
500/// # Examples
501/// ```
502/// use finance_solution::*;
503///
504/// let solution = amortization_solution(0.005, 24, 12_000.0, 0.0, false).unwrap();
505/// assert!(solution.payment().is_finite());
506/// assert_eq!(solution.series().len(), 24);
507///
508/// // Year-1 interest (months 1–12)
509/// let y1 = solution.cumipmt(1, 12).unwrap();
510/// assert!(y1.is_finite());
511/// ```
512///
513/// Match structured errors:
514/// ```
515/// use finance_solution::{amortization_solution, FinanceError};
516///
517/// match amortization_solution(0.01, 12, 5_000.0, 0.0, false) {
518///     Ok(sol) => {
519///         assert!(sol.payment().is_finite());
520///         sol.series().print_table(true, true);
521///     }
522///     Err(FinanceError::InvalidRate { rate }) => panic!("bad rate {rate}"),
523///     Err(e) => panic!("{e}"),
524/// }
525///
526/// assert!(matches!(
527///     amortization_solution(-1.5, 12, 5_000.0, 0.0, false),
528///     Err(FinanceError::InvalidRate { .. })
529/// ));
530/// ```
531pub fn amortization_solution<P, F, T>(
532    rate: f64,
533    periods: u32,
534    present_value: P,
535    future_value: F,
536    timing: T,
537) -> FinanceResult<AmortizationSolution>
538where
539    P: Into<f64> + Copy,
540    F: Into<f64> + Copy,
541    T: Into<crate::PaymentTiming>,
542{
543    let present_value = present_value.into();
544    let future_value = future_value.into();
545    let due_at_beginning = timing.into().is_beginning();
546
547    // Validation chain: compose with ? (design-pattern early return).
548    require_rate_gt_minus_one(rate)?;
549    require_finite("present_value", present_value)?;
550    require_finite("future_value", future_value)?;
551    if periods == 0 {
552        return Err(FinanceError::InvalidPeriod {
553            period: 0,
554            periods,
555            message: "periods must be greater than zero for an amortization schedule",
556        });
557    }
558
559    let pmt = crate::payment(rate, periods, present_value, future_value, due_at_beginning)?;
560
561    let rate_mult = 1.0 + rate;
562    let (formula, symbolic_formula) = if rate == 0.0 {
563        (
564            format!(
565                "{:.4} = -({:.4} + {:.4}) / {}",
566                pmt, present_value, future_value, periods
567            ),
568            "pmt = -(pv + fv) / n".to_string(),
569        )
570    } else if due_at_beginning {
571        (
572            format!(
573                "{:.4} = ((({:.4} * {:.6}^{}) + {:.4}) * {:.6}) / (({:.6}^{} - 1) * {:.6})",
574                pmt,
575                present_value,
576                rate_mult,
577                periods,
578                future_value,
579                -rate,
580                rate_mult,
581                periods,
582                rate_mult
583            ),
584            "pmt = (((pv * (1+r)^n) + fv) * -r) / (((1+r)^n - 1) * (1+r))".to_string(),
585        )
586    } else {
587        (
588            format!(
589                "{:.4} = ((({:.4} * {:.6}^{}) + {:.4}) * {:.6}) / ({:.6}^{} - 1)",
590                pmt, present_value, rate_mult, periods, future_value, -rate, rate_mult, periods
591            ),
592            "pmt = (((pv * (1+r)^n) + fv) * -r) / ((1+r)^n - 1)".to_string(),
593        )
594    };
595
596    Ok(AmortizationSolution::new(
597        rate,
598        periods,
599        present_value,
600        future_value,
601        due_at_beginning,
602        pmt,
603        formula,
604        symbolic_formula,
605    ))
606}
607
608// ---------------------------------------------------------------------------
609// Excel-style scalar helpers (delegate to solution series)
610// ---------------------------------------------------------------------------
611
612/// Interest portion of the payment for a single period (Excel `IPMT`).
613///
614/// # Arguments
615/// * `rate` – periodic rate
616/// * `period` – 1-based period index (`1..=periods`)
617/// * `periods` – total number of periods
618/// * `present_value` – principal / PV
619/// * `future_value` – residual value at end (often 0)
620/// * `timing` – [`PaymentTiming`] or `bool` (`false` = end of period / Excel `type=0`)
621///
622/// # Errors
623/// Propagates construction errors from [`amortization_solution`] and
624/// [`FinanceError::InvalidPeriod`] when `period` is out of range.
625///
626/// # Examples
627/// ```
628/// use finance_solution::*;
629/// let interest = ipmt(0.01, 1, 12, 10_000.0, 0.0, false).unwrap();
630/// assert!(interest < 0.0); // opposite sign of positive principal
631/// assert_rounded_2!(interest, -100.0);
632/// ```
633///
634/// For schedules and tables, prefer [`amortization_solution`]:
635/// ```
636/// use finance_solution::*;
637/// let s = amortization_solution(0.01, 12, 10_000.0, 0.0, false).unwrap();
638/// assert_approx_equal!(
639///     s.ipmt(1).unwrap(),
640///     ipmt(0.01, 1, 12, 10_000.0, 0.0, false).unwrap()
641/// );
642/// ```
643///
644/// Out-of-range period:
645/// ```
646/// use finance_solution::{ipmt, FinanceError};
647///
648/// match ipmt(0.01, 1, 12, 10_000.0, 0.0, false) {
649///     Ok(interest) => assert!(interest < 0.0),
650///     Err(FinanceError::InvalidPeriod { period, periods, .. }) => {
651///         panic!("period {period} not in 1..={periods}");
652///     }
653///     Err(e) => panic!("{e}"),
654/// }
655///
656/// assert!(matches!(
657///     ipmt(0.01, 0, 12, 10_000.0, 0.0, false),
658///     Err(FinanceError::InvalidPeriod { .. })
659/// ));
660/// ```
661pub fn ipmt<P, F, T>(
662    rate: f64,
663    period: u32,
664    periods: u32,
665    present_value: P,
666    future_value: F,
667    timing: T,
668) -> FinanceResult<f64>
669where
670    P: Into<f64> + Copy,
671    F: Into<f64> + Copy,
672    T: Into<crate::PaymentTiming>,
673{
674    amortization_solution(rate, periods, present_value, future_value, timing)?.ipmt(period)
675}
676
677/// Principal portion of the payment for a single period (Excel `PPMT`).
678///
679/// # Errors
680/// Same domain as [`ipmt`].
681///
682/// # Examples
683/// ```
684/// use finance_solution::*;
685/// let principal = ppmt(0.01, 1, 12, 10_000.0, 0.0, false).unwrap();
686/// let interest = ipmt(0.01, 1, 12, 10_000.0, 0.0, false).unwrap();
687/// let pmt = payment(0.01, 12, 10_000.0, 0.0, false).unwrap();
688/// assert_approx_equal!(principal + interest, pmt);
689/// ```
690pub fn ppmt<P, F, T>(
691    rate: f64,
692    period: u32,
693    periods: u32,
694    present_value: P,
695    future_value: F,
696    timing: T,
697) -> FinanceResult<f64>
698where
699    P: Into<f64> + Copy,
700    F: Into<f64> + Copy,
701    T: Into<crate::PaymentTiming>,
702{
703    amortization_solution(rate, periods, present_value, future_value, timing)?.ppmt(period)
704}
705
706/// Cumulative principal paid between two periods inclusive (Excel `CUMPRINC`).
707///
708/// `start_period` and `end_period` are 1-based; require `start_period <= end_period`.
709///
710/// # Errors
711/// Construction failures plus [`FinanceError::InvalidPeriod`] for bad ranges.
712pub fn cumprinc<P, F, T>(
713    rate: f64,
714    periods: u32,
715    present_value: P,
716    future_value: F,
717    start_period: u32,
718    end_period: u32,
719    timing: T,
720) -> FinanceResult<f64>
721where
722    P: Into<f64> + Copy,
723    F: Into<f64> + Copy,
724    T: Into<crate::PaymentTiming>,
725{
726    amortization_solution(rate, periods, present_value, future_value, timing)?
727        .cumprinc(start_period, end_period)
728}
729
730/// Cumulative interest paid between two periods inclusive (Excel `CUMIPMT`).
731///
732/// # Errors
733/// Same domain as [`cumprinc`].
734pub fn cumipmt<P, F, T>(
735    rate: f64,
736    periods: u32,
737    present_value: P,
738    future_value: F,
739    start_period: u32,
740    end_period: u32,
741    timing: T,
742) -> FinanceResult<f64>
743where
744    P: Into<f64> + Copy,
745    F: Into<f64> + Copy,
746    T: Into<crate::PaymentTiming>,
747{
748    amortization_solution(rate, periods, present_value, future_value, timing)?
749        .cumipmt(start_period, end_period)
750}
751
752// ---------------------------------------------------------------------------
753// Internals
754// ---------------------------------------------------------------------------
755
756fn validate_range(start_period: u32, end_period: u32, periods: u32) -> FinanceResult<()> {
757    if periods == 0 {
758        return Err(FinanceError::InvalidPeriod {
759            period: 0,
760            periods,
761            message: "periods must be greater than zero",
762        });
763    }
764    if start_period == 0 || start_period > periods {
765        return Err(FinanceError::InvalidPeriod {
766            period: start_period,
767            periods,
768            message: "start_period must be in 1..=periods",
769        });
770    }
771    if end_period == 0 || end_period > periods {
772        return Err(FinanceError::InvalidPeriod {
773            period: end_period,
774            periods,
775            message: "end_period must be in 1..=periods",
776        });
777    }
778    if start_period > end_period {
779        return Err(FinanceError::InvalidPeriod {
780            period: start_period,
781            periods,
782            message: "start_period must be <= end_period",
783        });
784    }
785    Ok(())
786}
787
788fn build_series(
789    rate: f64,
790    periods: u32,
791    present_value: f64,
792    _future_value: f64,
793    due_at_beginning: bool,
794    pmt: f64,
795    sum_of_payments: f64,
796    sum_of_interest: f64,
797) -> AmortizationSeries {
798    let mut rows = Vec::with_capacity(periods as usize);
799    let mut balance = present_value;
800    let mut payments_to_date = 0.0;
801    let mut principal_to_date = 0.0;
802    let mut interest_to_date = 0.0;
803
804    for period in 1..=periods {
805        let balance_at_start = balance;
806        let (interest, principal, formula, symbolic_formula) = if due_at_beginning && period == 1 {
807            (0.0, pmt, "0.0000".to_string(), "interest = 0".to_string())
808        } else {
809            let interest = -balance_at_start * rate;
810            let principal = pmt - interest;
811            let formula = format!(
812                "interest {:.4} = -({:.4} * {:.6}); principal {:.4} = pmt {:.4} - interest",
813                interest, balance_at_start, rate, principal, pmt
814            );
815            let symbolic =
816                "interest = -(balance_start * r); principal = pmt - interest".to_string();
817            (interest, principal, formula, symbolic)
818        };
819
820        balance += principal;
821        payments_to_date += pmt;
822        principal_to_date += principal;
823        interest_to_date += interest;
824
825        rows.push(AmortizationPeriod {
826            period,
827            rate,
828            payment: pmt,
829            principal,
830            interest,
831            principal_to_date,
832            interest_to_date,
833            principal_remaining: -(present_value + principal_to_date),
834            interest_remaining: sum_of_interest - interest_to_date,
835            payments_to_date,
836            payments_remaining: sum_of_payments - payments_to_date,
837            balance,
838            formula,
839            symbolic_formula,
840        });
841    }
842
843    AmortizationSeries::new(rows)
844}
845
846#[cfg(test)]
847mod tests {
848    use super::*;
849    use crate::*;
850
851    #[test]
852    fn test_ppmt_ipmt_sum_to_payment() {
853        let rate = 0.08 / 12.0;
854        let periods = 60;
855        let pv = 13_000.0;
856        let pmt = payment(rate, periods, pv, 0.0, false).unwrap();
857        for period in 1..=periods {
858            let princ = ppmt(rate, period, periods, pv, 0.0, false).unwrap();
859            let int = ipmt(rate, period, periods, pv, 0.0, false).unwrap();
860            assert_approx_equal!(princ + int, pmt);
861        }
862    }
863
864    #[test]
865    fn test_cumprinc_full_term_near_principal() {
866        let rate = 0.08 / 12.0;
867        let periods = 60;
868        let pv = 13_000.0;
869        let total_principal = cumprinc(rate, periods, pv, 0.0, 1, periods, false).unwrap();
870        assert_approx_equal!(total_principal, -pv);
871    }
872
873    #[test]
874    fn test_cumipmt_matches_sum_of_interest() {
875        let rate = 0.08 / 12.0;
876        let periods = 24;
877        let pv = 10_000.0;
878        let solution = amortization_solution(rate, periods, pv, 0.0, false).unwrap();
879        let cum_int = cumipmt(rate, periods, pv, 0.0, 1, periods, false).unwrap();
880        assert_approx_equal!(cum_int, solution.sum_of_interest());
881    }
882
883    #[test]
884    fn test_first_period_interest() {
885        assert_rounded_2!(ipmt(0.01, 1, 12, 10_000.0, 0.0, false).unwrap(), -100.0);
886    }
887
888    #[test]
889    fn test_ipmt_bad_period() {
890        assert!(matches!(
891            ipmt(0.01, 0, 12, 1000.0, 0.0, false),
892            Err(FinanceError::InvalidPeriod { .. })
893        ));
894        assert!(matches!(
895            ipmt(0.01, 13, 12, 1000.0, 0.0, false),
896            Err(FinanceError::InvalidPeriod { .. })
897        ));
898    }
899
900    #[test]
901    fn test_solution_series_len_and_formulas() {
902        let s = amortization_solution(0.01, 6, 1000.0, 0.0, false).unwrap();
903        let series = s.series();
904        assert_eq!(series.len(), 6);
905        assert!(!s.formula().is_empty());
906        assert!(!s.symbolic_formula().is_empty());
907        assert!(!series[0].formula().is_empty());
908        assert_approx_equal!(s.ipmt(1).unwrap(), series[0].interest());
909        assert_approx_equal!(s.ppmt(3).unwrap(), series[2].principal());
910    }
911
912    #[test]
913    fn test_amortization_invalid_rate() {
914        assert!(matches!(
915            amortization_solution(-1.0, 12, 1000.0, 0.0, false),
916            Err(FinanceError::InvalidRate { .. })
917        ));
918    }
919
920    #[test]
921    fn test_amortization_zero_periods() {
922        assert!(matches!(
923            amortization_solution(0.01, 0, 1000.0, 0.0, false),
924            Err(FinanceError::InvalidPeriod { .. })
925        ));
926    }
927
928    #[test]
929    fn test_cumipmt_invalid_range() {
930        let s = amortization_solution(0.01, 12, 1000.0, 0.0, false).unwrap();
931        assert!(s.cumipmt(5, 3).is_err());
932        assert!(s.cumipmt(0, 5).is_err());
933        assert!(s.cumipmt(1, 13).is_err());
934    }
935
936    #[test]
937    fn test_payment_timing_enum_matches_bool() {
938        let a = amortization_solution(0.01, 12, 5_000.0, 0.0, false).unwrap();
939        let b = amortization_solution(0.01, 12, 5_000.0, 0.0, PaymentTiming::EndOfPeriod).unwrap();
940        assert_approx_equal!(a.payment(), b.payment());
941        let due = amortization_solution(0.01, 12, 5_000.0, 0.0, true).unwrap();
942        assert!(due.payment().abs() < a.payment().abs());
943    }
944}