formualizer_eval/builtins/financial/depreciation.rs
1//! Depreciation functions: SLN, SYD, DB, DDB
2
3use crate::args::ArgSchema;
4use crate::function::Function;
5use crate::traits::{ArgumentHandle, CalcValue, FunctionContext};
6use formualizer_common::{ExcelError, LiteralValue};
7use formualizer_macros::func_caps;
8
9fn coerce_num(arg: &ArgumentHandle) -> Result<f64, ExcelError> {
10 let v = arg.value()?.into_literal();
11 match v {
12 LiteralValue::Number(f) => Ok(f),
13 LiteralValue::Int(i) => Ok(i as f64),
14 LiteralValue::Boolean(b) => Ok(if b { 1.0 } else { 0.0 }),
15 LiteralValue::Empty => Ok(0.0),
16 LiteralValue::Error(e) => Err(e),
17 _ => Err(ExcelError::new_value()),
18 }
19}
20
21/// Returns straight-line depreciation for a single period.
22///
23/// `SLN` spreads the depreciable amount (`cost - salvage`) evenly across `life` periods.
24///
25/// # Remarks
26/// - Formula: `(cost - salvage) / life`.
27/// - `life` must be non-zero; `life = 0` returns `#DIV/0!`.
28/// - This function returns the algebraic result: if `salvage > cost`, depreciation is negative.
29/// - Inputs are interpreted as scalar numeric values in matching currency/period units.
30///
31/// # Examples
32///
33/// ```yaml,sandbox
34/// title: "Straight-line yearly depreciation"
35/// formula: "=SLN(10000, 1000, 9)"
36/// expected: 1000
37/// ```
38///
39/// ```yaml,sandbox
40/// title: "Negative depreciation when salvage exceeds cost"
41/// formula: "=SLN(1000, 1200, 2)"
42/// expected: -100
43/// ```
44/// ```yaml,docs
45/// related:
46/// - SYD
47/// - DB
48/// - DDB
49/// faq:
50/// - q: "Can `SLN` return a negative value?"
51/// a: "Yes. If `salvage > cost`, `(cost - salvage) / life` is negative."
52/// - q: "What happens when `life` is zero?"
53/// a: "`SLN` returns `#DIV/0!`."
54/// ```
55#[derive(Debug)]
56pub struct SlnFn;
57/// [formualizer-docgen:schema:start]
58/// Name: SLN
59/// Type: SlnFn
60/// Min args: 3
61/// Max args: 3
62/// Variadic: false
63/// Signature: SLN(arg1: number@scalar, arg2: number@scalar, arg3: number@scalar)
64/// Arg schema: arg1{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}; arg2{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}; arg3{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}
65/// Caps: PURE
66/// [formualizer-docgen:schema:end]
67impl Function for SlnFn {
68 func_caps!(PURE);
69 fn name(&self) -> &'static str {
70 "SLN"
71 }
72 fn min_args(&self) -> usize {
73 3
74 }
75 fn arg_schema(&self) -> &'static [ArgSchema] {
76 use std::sync::LazyLock;
77 static SCHEMA: LazyLock<Vec<ArgSchema>> = LazyLock::new(|| {
78 vec![
79 ArgSchema::number_lenient_scalar(),
80 ArgSchema::number_lenient_scalar(),
81 ArgSchema::number_lenient_scalar(),
82 ]
83 });
84 &SCHEMA[..]
85 }
86 fn eval<'a, 'b, 'c>(
87 &self,
88 args: &'c [ArgumentHandle<'a, 'b>],
89 _ctx: &dyn FunctionContext<'b>,
90 ) -> Result<CalcValue<'b>, ExcelError> {
91 let cost = coerce_num(&args[0])?;
92 let salvage = coerce_num(&args[1])?;
93 let life = coerce_num(&args[2])?;
94
95 if life == 0.0 {
96 return Ok(CalcValue::Scalar(
97 LiteralValue::Error(ExcelError::new_div()),
98 ));
99 }
100
101 let depreciation = (cost - salvage) / life;
102 Ok(CalcValue::Scalar(LiteralValue::Number(depreciation)))
103 }
104}
105
106/// Returns sum-of-years'-digits depreciation for a requested period.
107///
108/// `SYD` applies accelerated depreciation by weighting earlier periods more heavily.
109///
110/// # Remarks
111/// - Formula: `(cost - salvage) * (life - per + 1) / (life * (life + 1) / 2)`.
112/// - `life` and `per` must satisfy: `life > 0`, `per > 0`, and `per <= life`; otherwise returns `#NUM!`.
113/// - The function uses the provided numeric values directly (no integer-only enforcement).
114/// - Result sign follows `(cost - salvage)`: positive for typical depreciation expense, negative if `salvage > cost`.
115///
116/// # Examples
117///
118/// ```yaml,sandbox
119/// title: "First SYD period"
120/// formula: "=SYD(10000, 1000, 5, 1)"
121/// expected: 3000
122/// ```
123///
124/// ```yaml,sandbox
125/// title: "Final SYD period"
126/// formula: "=SYD(10000, 1000, 5, 5)"
127/// expected: 600
128/// ```
129/// ```yaml,docs
130/// related:
131/// - SLN
132/// - DB
133/// - DDB
134/// faq:
135/// - q: "Does `SYD` require integer `life` and `per`?"
136/// a: "No strict integer check is enforced; it uses provided numeric values directly after domain validation."
137/// - q: "Which period values are valid?"
138/// a: "`per` must satisfy `0 < per <= life`, and `life` must be positive; otherwise `#NUM!` is returned."
139/// ```
140#[derive(Debug)]
141pub struct SydFn;
142/// [formualizer-docgen:schema:start]
143/// Name: SYD
144/// Type: SydFn
145/// Min args: 4
146/// Max args: 4
147/// Variadic: false
148/// Signature: SYD(arg1: number@scalar, arg2: number@scalar, arg3: number@scalar, arg4: number@scalar)
149/// Arg schema: arg1{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}; arg2{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}; arg3{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}; arg4{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}
150/// Caps: PURE
151/// [formualizer-docgen:schema:end]
152impl Function for SydFn {
153 func_caps!(PURE);
154 fn name(&self) -> &'static str {
155 "SYD"
156 }
157 fn min_args(&self) -> usize {
158 4
159 }
160 fn arg_schema(&self) -> &'static [ArgSchema] {
161 use std::sync::LazyLock;
162 static SCHEMA: LazyLock<Vec<ArgSchema>> = LazyLock::new(|| {
163 vec![
164 ArgSchema::number_lenient_scalar(),
165 ArgSchema::number_lenient_scalar(),
166 ArgSchema::number_lenient_scalar(),
167 ArgSchema::number_lenient_scalar(),
168 ]
169 });
170 &SCHEMA[..]
171 }
172 fn eval<'a, 'b, 'c>(
173 &self,
174 args: &'c [ArgumentHandle<'a, 'b>],
175 _ctx: &dyn FunctionContext<'b>,
176 ) -> Result<CalcValue<'b>, ExcelError> {
177 let cost = coerce_num(&args[0])?;
178 let salvage = coerce_num(&args[1])?;
179 let life = coerce_num(&args[2])?;
180 let per = coerce_num(&args[3])?;
181
182 if life <= 0.0 || per <= 0.0 || per > life {
183 return Ok(CalcValue::Scalar(
184 LiteralValue::Error(ExcelError::new_num()),
185 ));
186 }
187
188 // Sum of years = life * (life + 1) / 2
189 let sum_of_years = life * (life + 1.0) / 2.0;
190
191 // SYD = (cost - salvage) * (life - per + 1) / sum_of_years
192 let depreciation = (cost - salvage) * (life - per + 1.0) / sum_of_years;
193
194 Ok(CalcValue::Scalar(LiteralValue::Number(depreciation)))
195 }
196}
197
198/// Returns fixed-declining-balance depreciation for a specified period.
199///
200/// `DB` computes per-period depreciation using a declining-balance rate and an optional
201/// first-year month proration.
202///
203/// # Remarks
204/// - Parameters: `cost`, `salvage`, `life`, `period`, and optional `month` (default `12`).
205/// - `month` must be in `1..=12`; `life` and `period` must be positive; invalid values return `#NUM!`.
206/// - `life` and `period` are truncated to integers for period checks and iteration.
207/// - The declining rate is rounded to three decimals; if `cost <= 0` or `salvage <= 0`, this implementation uses a rate of `1.0`.
208/// - Returned value is the period depreciation amount (generally positive expense, but sign follows provided inputs).
209///
210/// # Examples
211///
212/// ```yaml,sandbox
213/// title: "First full-year DB period"
214/// formula: "=DB(10000, 1000, 5, 1)"
215/// expected: 3690
216/// ```
217///
218/// ```yaml,sandbox
219/// title: "Fractional period input is truncated"
220/// formula: "=DB(10000, 1000, 5, 2.9)"
221/// expected: 2328.39
222/// ```
223/// ```yaml,docs
224/// related:
225/// - DDB
226/// - SYD
227/// - SLN
228/// faq:
229/// - q: "How is `month` used in `DB`?"
230/// a: "`month` prorates the first-year depreciation; if omitted it defaults to `12`."
231/// - q: "Why can fractional `period` inputs behave like integers?"
232/// a: "`DB` truncates `life` and `period` to integers for iteration and period bounds."
233/// ```
234#[derive(Debug)]
235pub struct DbFn;
236/// [formualizer-docgen:schema:start]
237/// Name: DB
238/// Type: DbFn
239/// Min args: 4
240/// Max args: variadic
241/// Variadic: true
242/// Signature: DB(arg1: number@scalar, arg2: number@scalar, arg3: number@scalar, arg4: number@scalar, arg5...: number@scalar)
243/// Arg schema: arg1{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}; arg2{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}; arg3{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}; arg4{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}; arg5{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}
244/// Caps: PURE
245/// [formualizer-docgen:schema:end]
246impl Function for DbFn {
247 func_caps!(PURE);
248 fn name(&self) -> &'static str {
249 "DB"
250 }
251 fn min_args(&self) -> usize {
252 4
253 }
254 fn variadic(&self) -> bool {
255 true
256 }
257 fn arg_schema(&self) -> &'static [ArgSchema] {
258 use std::sync::LazyLock;
259 static SCHEMA: LazyLock<Vec<ArgSchema>> = LazyLock::new(|| {
260 vec![
261 ArgSchema::number_lenient_scalar(),
262 ArgSchema::number_lenient_scalar(),
263 ArgSchema::number_lenient_scalar(),
264 ArgSchema::number_lenient_scalar(),
265 ArgSchema::number_lenient_scalar(),
266 ]
267 });
268 &SCHEMA[..]
269 }
270 fn eval<'a, 'b, 'c>(
271 &self,
272 args: &'c [ArgumentHandle<'a, 'b>],
273 _ctx: &dyn FunctionContext<'b>,
274 ) -> Result<CalcValue<'b>, ExcelError> {
275 let cost = coerce_num(&args[0])?;
276 let salvage = coerce_num(&args[1])?;
277 let life = coerce_num(&args[2])?;
278 let period = coerce_num(&args[3])?;
279 let month = if args.len() > 4 {
280 coerce_num(&args[4])?
281 } else {
282 12.0
283 };
284
285 if life <= 0.0 || period <= 0.0 || !(1.0..=12.0).contains(&month) {
286 return Ok(CalcValue::Scalar(
287 LiteralValue::Error(ExcelError::new_num()),
288 ));
289 }
290
291 let life_int = life.trunc() as i32;
292 let period_int = period.trunc() as i32;
293
294 if period_int < 1 || period_int > life_int + 1 {
295 return Ok(CalcValue::Scalar(
296 LiteralValue::Error(ExcelError::new_num()),
297 ));
298 }
299
300 // Calculate rate (rounded to 3 decimal places)
301 let rate = if cost <= 0.0 || salvage <= 0.0 {
302 1.0
303 } else {
304 let r = 1.0 - (salvage / cost).powf(1.0 / life);
305 (r * 1000.0).round() / 1000.0
306 };
307
308 let mut total_depreciation = 0.0;
309 let value = cost;
310
311 for p in 1..=period_int {
312 let depreciation = if p == 1 {
313 // First period: prorated
314 value * rate * month / 12.0
315 } else if p == life_int + 1 {
316 // Last period (if partial year): remaining value minus salvage
317 (value - total_depreciation - salvage)
318 .max(0.0)
319 .min(value - total_depreciation)
320 } else {
321 (value - total_depreciation) * rate
322 };
323
324 if p == period_int {
325 return Ok(CalcValue::Scalar(LiteralValue::Number(depreciation)));
326 }
327
328 total_depreciation += depreciation;
329 }
330
331 Ok(CalcValue::Scalar(LiteralValue::Number(0.0)))
332 }
333}
334
335/// Returns declining-balance depreciation for a period using a configurable acceleration factor.
336///
337/// `DDB` defaults to the double-declining method (`factor = 2`) and applies a salvage floor so
338/// book value does not fall below `salvage`.
339///
340/// # Remarks
341/// - Parameters: `cost`, `salvage`, `life`, `period`, and optional `factor` (default `2`).
342/// - Input constraints: `cost >= 0`, `salvage >= 0`, `life > 0`, `period > 0`, `factor > 0`, and `period <= life`; violations return `#NUM!`.
343/// - Per-period rate is `factor / life`.
344/// - This implementation processes the integer part of `period` and then blends with the next period for a fractional remainder.
345/// - Result is the period depreciation amount; with valid inputs above it is non-negative.
346///
347/// # Examples
348///
349/// ```yaml,sandbox
350/// title: "Default double-declining first period"
351/// formula: "=DDB(10000, 1000, 5, 1)"
352/// expected: 4000
353/// ```
354///
355/// ```yaml,sandbox
356/// title: "Using a custom factor"
357/// formula: "=DDB(10000, 1000, 5, 1, 1.5)"
358/// expected: 3000
359/// ```
360/// ```yaml,docs
361/// related:
362/// - DB
363/// - SYD
364/// - SLN
365/// faq:
366/// - q: "What does the optional `factor` control?"
367/// a: "It sets the per-period declining rate as `factor / life`; `2` gives double-declining balance."
368/// - q: "When does `DDB` return `#NUM!`?"
369/// a: "Invalid non-positive inputs (`life`, `period`, `factor`), negative `cost`/`salvage`, or `period > life`."
370/// ```
371#[derive(Debug)]
372pub struct DdbFn;
373/// [formualizer-docgen:schema:start]
374/// Name: DDB
375/// Type: DdbFn
376/// Min args: 4
377/// Max args: variadic
378/// Variadic: true
379/// Signature: DDB(arg1: number@scalar, arg2: number@scalar, arg3: number@scalar, arg4: number@scalar, arg5...: number@scalar)
380/// Arg schema: arg1{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}; arg2{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}; arg3{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}; arg4{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}; arg5{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}
381/// Caps: PURE
382/// [formualizer-docgen:schema:end]
383impl Function for DdbFn {
384 func_caps!(PURE);
385 fn name(&self) -> &'static str {
386 "DDB"
387 }
388 fn min_args(&self) -> usize {
389 4
390 }
391 fn variadic(&self) -> bool {
392 true
393 }
394 fn arg_schema(&self) -> &'static [ArgSchema] {
395 use std::sync::LazyLock;
396 static SCHEMA: LazyLock<Vec<ArgSchema>> = LazyLock::new(|| {
397 vec![
398 ArgSchema::number_lenient_scalar(),
399 ArgSchema::number_lenient_scalar(),
400 ArgSchema::number_lenient_scalar(),
401 ArgSchema::number_lenient_scalar(),
402 ArgSchema::number_lenient_scalar(),
403 ]
404 });
405 &SCHEMA[..]
406 }
407 fn eval<'a, 'b, 'c>(
408 &self,
409 args: &'c [ArgumentHandle<'a, 'b>],
410 _ctx: &dyn FunctionContext<'b>,
411 ) -> Result<CalcValue<'b>, ExcelError> {
412 let cost = coerce_num(&args[0])?;
413 let salvage = coerce_num(&args[1])?;
414 let life = coerce_num(&args[2])?;
415 let period = coerce_num(&args[3])?;
416 let factor = if args.len() > 4 {
417 coerce_num(&args[4])?
418 } else {
419 2.0
420 };
421
422 if cost < 0.0 || salvage < 0.0 || life <= 0.0 || period <= 0.0 || factor <= 0.0 {
423 return Ok(CalcValue::Scalar(
424 LiteralValue::Error(ExcelError::new_num()),
425 ));
426 }
427
428 if period > life {
429 return Ok(CalcValue::Scalar(
430 LiteralValue::Error(ExcelError::new_num()),
431 ));
432 }
433
434 let rate = factor / life;
435 let mut value = cost;
436 let mut depreciation = 0.0;
437
438 for p in 1..=(period.trunc() as i32) {
439 depreciation = value * rate;
440 // Don't depreciate below salvage value
441 if value - depreciation < salvage {
442 depreciation = (value - salvage).max(0.0);
443 }
444 value -= depreciation;
445 }
446
447 // TODO: Handle fractional period - this logic is incorrect and doesn't match Excel
448 // Excel returns an error for non-integer periods. This weighted average approach
449 // should be removed or replaced with proper error handling.
450 let frac = period.fract();
451 if frac > 0.0 {
452 let next_depreciation = value * rate;
453 let next_depreciation = if value - next_depreciation < salvage {
454 (value - salvage).max(0.0)
455 } else {
456 next_depreciation
457 };
458 depreciation = depreciation * (1.0 - frac) + next_depreciation * frac;
459 }
460
461 Ok(CalcValue::Scalar(LiteralValue::Number(depreciation)))
462 }
463}
464
465pub fn register_builtins() {
466 use std::sync::Arc;
467 crate::function_registry::register_function(Arc::new(SlnFn));
468 crate::function_registry::register_function(Arc::new(SydFn));
469 crate::function_registry::register_function(Arc::new(DbFn));
470 crate::function_registry::register_function(Arc::new(DdbFn));
471}