1use crate::util::error::{require_finite, require_rate, FinanceError, FinanceResult};
11
12pub 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
59pub 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
83pub 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
101pub 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 let (num_periods, formula) = if periodic_rate == 0.0 {
167 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#[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}