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}