Skip to main content

finance_solution/cashflow/
nper.rs

1//! **Number of periods with payments (NPER).** How many periods for an annuity cashflow to grow from
2//! a present value to a future value at a periodic rate?
3//!
4//! Excel / Google Sheets equivalent: `NPER`.
5//!
6//! # Error handling (v0.1+)
7//!
8//! All entry points return [`FinanceResult`]. Invalid rates, non-negative payments (Excel sign
9//! convention), or unsolvable combinations yield [`FinanceError`] — they do not panic.
10use crate::util::error::{require_finite, require_rate, FinanceError, FinanceResult};
11
12/// Returns the number of periods for an annuity (payments) to reach a future value.
13///
14/// Related functions:
15/// * [`nper_solution`] – same calculation with a solution struct
16/// * [`nper_due`] – payments due at the beginning of each period
17///
18/// Formula (end-of-period payments):
19///
20/// ```text
21/// n = ln( (pmt - fv * r) / (pmt + pv * r) ) / ln(1 + r)
22/// ```
23///
24/// # Arguments
25/// * `periodic_rate` – growth rate per period (e.g. `0.05` for 5%)
26/// * `payment` – payment per period; **must be negative** (Excel convention) when PV/FV are ≥ 0
27/// * `present_value` – present value (≥ 0 in the Excel-style sign convention used here)
28/// * `future_value` – future value (≥ 0); at least one of PV/FV must be nonzero
29///
30/// # Errors
31/// Returns [`FinanceError`] when rate/payment/PV/FV cannot produce a finite period count.
32///
33/// # Examples
34/// ```
35/// use finance_solution::{nper, FinanceError};
36///
37/// let n = nper(0.034, -500.0, 1000.0, 20_000.0).unwrap();
38/// assert!((n - 27.7879559).abs() < 1e-4);
39///
40/// assert!(matches!(
41///     nper(0.05, 100.0, 0.0, 1000.0),
42///     Err(FinanceError::InvalidCashflow { .. })
43/// ));
44/// ```
45pub fn nper<C, P, F>(
46    periodic_rate: f64,
47    payment: C,
48    present_value: P,
49    future_value: F,
50) -> FinanceResult<f64>
51where
52    C: Into<f64> + Copy,
53    P: Into<f64> + Copy,
54    F: Into<f64> + Copy,
55{
56    Ok(nper_solution(periodic_rate, payment, present_value, future_value)?.periods)
57}
58
59/// [`nper`] with a solution struct (formula string + inputs).
60///
61/// # Errors
62/// Same domain rules as [`nper`].
63pub fn nper_solution<C, P, F>(
64    periodic_rate: f64,
65    payment: C,
66    present_value: P,
67    future_value: F,
68) -> FinanceResult<NperSolution>
69where
70    C: Into<f64> + Copy,
71    P: Into<f64> + Copy,
72    F: Into<f64> + Copy,
73{
74    nper_solution_internal(
75        periodic_rate,
76        payment.into(),
77        present_value.into(),
78        future_value.into(),
79        false,
80    )
81}
82
83/// Number of periods when payments are due at the **beginning** of each period (Excel `type=1`).
84///
85/// # Errors
86/// Same domain rules as [`nper`].
87pub fn nper_due<C, P, F>(
88    periodic_rate: f64,
89    payment: C,
90    present_value: P,
91    future_value: F,
92) -> FinanceResult<f64>
93where
94    C: Into<f64> + Copy,
95    P: Into<f64> + Copy,
96    F: Into<f64> + Copy,
97{
98    Ok(nper_due_solution(periodic_rate, payment, present_value, future_value)?.periods)
99}
100
101/// [`nper_due`] with a solution struct.
102///
103/// # Errors
104/// Same domain rules as [`nper`].
105pub fn nper_due_solution<C, P, F>(
106    periodic_rate: f64,
107    payment: C,
108    present_value: P,
109    future_value: F,
110) -> FinanceResult<NperSolution>
111where
112    C: Into<f64> + Copy,
113    P: Into<f64> + Copy,
114    F: Into<f64> + Copy,
115{
116    nper_solution_internal(
117        periodic_rate,
118        payment.into(),
119        present_value.into(),
120        future_value.into(),
121        true,
122    )
123}
124
125fn nper_solution_internal(
126    periodic_rate: f64,
127    payment: f64,
128    present_value: f64,
129    future_value: f64,
130    due_at_beginning: bool,
131) -> FinanceResult<NperSolution> {
132    require_rate(periodic_rate)?;
133    require_finite("payment", payment)?;
134    require_finite("present_value", present_value)?;
135    require_finite("future_value", future_value)?;
136
137    if present_value < 0.0 || future_value < 0.0 {
138        return Err(FinanceError::InvalidCashflow {
139            message: "nper expects present_value and future_value >= 0 (Excel-style)",
140        });
141    }
142    if present_value + future_value <= 0.0 {
143        return Err(FinanceError::InvalidCashflow {
144            message: "either present_value and/or future_value must be greater than 0",
145        });
146    }
147    if payment >= 0.0 {
148        return Err(FinanceError::InvalidCashflow {
149            message: "payment must be negative (Excel / Google Sheets convention)",
150        });
151    }
152    if periodic_rate == -1.0 {
153        return Err(FinanceError::InvalidRate {
154            rate: periodic_rate,
155        });
156    }
157
158    // For annuity-due, Excel adjusts by treating the rate factor on the payment side.
159    // Standard approach: nper_due uses the same formula with an adjusted present value
160    // relationship. We use:
161    //   n = ln((pmt - fv*r) / (pmt + pv*r)) / ln(1+r)   for type=0
162    // For type=1 (due), the closed form is:
163    //   n = ln((pmt - fv*r) / (pmt + pv*r + pmt*r)) / ln(1+r)  ... when r != 0
164    // which is equivalent to dividing the payment-side by shifting interest on first period.
165
166    let (num_periods, formula) = if periodic_rate == 0.0 {
167        // No interest: periods = -(pv + fv) / pmt
168        let n = -(present_value + future_value) / payment;
169        if !n.is_finite() || n < 0.0 {
170            return Err(FinanceError::Unsolvable {
171                message: "nper with zero rate produced a non-finite or negative result",
172            });
173        }
174        let formula = format!("-({} + {}) / {}", present_value, future_value, payment);
175        (n, formula)
176    } else {
177        let (numer, denom_inner) = if due_at_beginning {
178            let pmt_adj = payment * (1.0 + periodic_rate);
179            (
180                pmt_adj - future_value * periodic_rate,
181                pmt_adj + present_value * periodic_rate,
182            )
183        } else {
184            (
185                payment - future_value * periodic_rate,
186                payment + present_value * periodic_rate,
187            )
188        };
189
190        if denom_inner == 0.0 || numer / denom_inner <= 0.0 {
191            return Err(FinanceError::Unsolvable {
192                message: "nper arguments do not admit a real solution (check signs and magnitudes)",
193            });
194        }
195
196        let ratio = numer / denom_inner;
197        let n = ratio.ln() / (1.0 + periodic_rate).ln();
198        if !n.is_finite() || n < 0.0 {
199            return Err(FinanceError::Unsolvable {
200                message: "nper produced a non-finite or negative period count",
201            });
202        }
203
204        let formula = if due_at_beginning {
205            format!(
206                "ln(({}*(1+{}) - {}*{}) / ({}*(1+{}) + {}*{})) / ln(1 + {})",
207                payment,
208                periodic_rate,
209                future_value,
210                periodic_rate,
211                payment,
212                periodic_rate,
213                present_value,
214                periodic_rate,
215                periodic_rate
216            )
217        } else {
218            format!(
219                "ln(({} - {}*{}) / ({} + {}*{})) / ln(1 + {})",
220                payment,
221                future_value,
222                periodic_rate,
223                payment,
224                present_value,
225                periodic_rate,
226                periodic_rate
227            )
228        };
229        (n, formula)
230    };
231
232    Ok(NperSolution::new(
233        periodic_rate,
234        num_periods,
235        payment,
236        present_value,
237        future_value,
238        due_at_beginning,
239        formula,
240    ))
241}
242
243/// Solution struct for an NPER calculation.
244#[derive(Debug, Clone)]
245pub struct NperSolution {
246    pub periodic_rate: f64,
247    pub periods: f64,
248    pub payment: f64,
249    pub present_value_total: f64,
250    pub future_value_total: f64,
251    pub due_at_beginning: bool,
252    pub formula: String,
253}
254
255impl NperSolution {
256    pub fn new(
257        periodic_rate: f64,
258        periods: f64,
259        payment: f64,
260        present_value_total: f64,
261        future_value_total: f64,
262        due_at_beginning: bool,
263        formula: String,
264    ) -> Self {
265        Self {
266            periodic_rate,
267            periods,
268            payment,
269            present_value_total,
270            future_value_total,
271            due_at_beginning,
272            formula,
273        }
274    }
275
276    pub fn periods(&self) -> f64 {
277        self.periods
278    }
279
280    pub fn formula(&self) -> &str {
281        &self.formula
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use crate::{assert_approx_equal, round_6};
289
290    #[test]
291    fn test_nper_excel_values() {
292        assert_eq!(
293            round_6(27.7879559),
294            round_6(nper(0.034, -500, 1000, 20_000).unwrap())
295        );
296        assert_eq!(
297            round_6(59.76100743),
298            round_6(nper(0.034, -50, 1000, 2_000).unwrap())
299        );
300        assert_eq!(
301            round_6(25.68169193),
302            round_6(nper(0.034, -50, 0, 2_000).unwrap())
303        );
304        assert_eq!(
305            round_6(80.18661533),
306            round_6(nper(0.034, -5, 0, 2_000).unwrap())
307        );
308        assert_eq!(
309            round_6(106.3368288),
310            round_6(nper(0.034, -200, 0, 200_000).unwrap())
311        );
312    }
313
314    #[test]
315    fn test_nper_zero_rate() {
316        assert_approx_equal!(nper(0.0, -100.0, 0.0, 1000.0).unwrap(), 10.0);
317    }
318
319    #[test]
320    fn test_nper_due_less_or_equal_end() {
321        let end = nper(0.05, -100.0, 0.0, 1000.0).unwrap();
322        let due = nper_due(0.05, -100.0, 0.0, 1000.0).unwrap();
323        assert!(due <= end);
324    }
325
326    #[test]
327    fn test_nper_rejects_positive_payment() {
328        assert!(nper(0.05, 100.0, 0.0, 1000.0).is_err());
329    }
330
331    #[test]
332    fn test_nper_err_rate_inf() {
333        assert!(nper(1_f64 / 0_f64, -500, 1000, 20_000).is_err());
334    }
335
336    #[test]
337    fn test_nper_err_positive_payment() {
338        assert!(nper(0.034, 500, 1000, 20_000).is_err());
339    }
340
341    #[test]
342    fn test_nper_err_zero_payment() {
343        assert!(nper(0.034, 0, 1000, 20_000).is_err());
344    }
345}