Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions base/src/expressions/parser/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,13 @@ impl<'a> Parser<'a> {
args,
};
}
let trimmed_name = name.trim_start_matches("_xlfn.");
if trimmed_name.eq_ignore_ascii_case("FVSCHEDULE") {
return Node::FunctionKind {
kind: Function::Fvschedule,
args,
};
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoded FVSCHEDULE bypasses locale data pattern

Low Severity

FVSCHEDULE has a hardcoded parser fallback that bypasses the standard language.functions.lookup() pattern used by all other functions. This check also redundantly calls name.trim_start_matches("_xlfn.") which was already called on line 783. The proper fix would be to add fvschedule to the language data structure in base/src/language/mod.rs.

Fix in Cursor Fix in Web

return Node::InvalidFunctionKind { name, args };
}
let context = &self.context;
Expand Down
10 changes: 10 additions & 0 deletions base/src/expressions/parser/static_analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,14 @@ fn args_signature_irr(arg_count: usize) -> Vec<Signature> {
}
}

fn args_signature_fvschedule(arg_count: usize) -> Vec<Signature> {
if arg_count == 2 {
vec![Signature::Scalar, Signature::Vector]
} else {
vec![Signature::Error; arg_count]
}
}

fn args_signature_xirr(arg_count: usize) -> Vec<Signature> {
if arg_count == 2 {
vec![Signature::Vector; arg_count]
Expand Down Expand Up @@ -753,6 +761,7 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec<Signatu
Function::Dollarfr => args_signature_scalars(arg_count, 2, 0),
Function::Effect => args_signature_scalars(arg_count, 2, 0),
Function::Fv => args_signature_scalars(arg_count, 3, 2),
Function::Fvschedule => args_signature_fvschedule(arg_count),
Function::Ipmt => args_signature_scalars(arg_count, 4, 2),
Function::Irr => args_signature_irr(arg_count),
Function::Ispmt => args_signature_scalars(arg_count, 4, 0),
Expand Down Expand Up @@ -1147,6 +1156,7 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult {
Function::Dollarfr => not_implemented(args),
Function::Effect => not_implemented(args),
Function::Fv => not_implemented(args),
Function::Fvschedule => not_implemented(args),
Function::Ipmt => not_implemented(args),
Function::Irr => not_implemented(args),
Function::Ispmt => not_implemented(args),
Expand Down
29 changes: 29 additions & 0 deletions base/src/functions/financial.rs
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,35 @@ impl<'a> Model<'a> {
}
}

// FVSCHEDULE(principal, schedule)
pub(crate) fn fn_fvschedule(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() != 2 {
return CalcResult::new_args_number_error(cell);
}
let principal = match self.get_number(&args[0], cell) {
Ok(f) => f,
Err(s) => return s,
};
let schedule = match self.get_array_of_numbers(&args[1], &cell) {
Ok(s) => s,
Err(err) => return err,
};
let mut result = principal;
for rate in schedule {
if rate <= -1.0 {
return CalcResult::new_error(Error::NUM, cell, "Rate must be > -1".to_string());
}
result *= 1.0 + rate;
}
if result.is_infinite() {
return CalcResult::new_error(Error::DIV, cell, "Division by 0".to_string());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrong error type for infinite result in FVSCHEDULE

Low Severity

The fn_fvschedule function returns Error::DIV with message "Division by 0" when result.is_infinite(), but FVSCHEDULE only performs multiplication operations. An infinite result from multiplication is an overflow, not division by zero. Other functions in the codebase (such as in bessel.rs and complex.rs) correctly use Error::NUM for infinite/NaN results from non-division operations.

Fix in Cursor Fix in Web

}
if result.is_nan() {
return CalcResult::new_error(Error::NUM, cell, "Invalid result".to_string());
}
CalcResult::Number(result)
}

// IPMT(rate, per, nper, pv, [fv], [type])
pub(crate) fn fn_ipmt(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
let arg_count = args.len();
Expand Down
6 changes: 5 additions & 1 deletion base/src/functions/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,7 @@ pub enum Function {
Dollarfr,
Effect,
Fv,
Fvschedule,
Ipmt,
Irr,
Ispmt,
Expand Down Expand Up @@ -1074,6 +1075,7 @@ impl Function {
Function::Dollarfr => functions.dollarfr.clone(),
Function::Effect => functions.effect.clone(),
Function::Fv => functions.fv.clone(),
Function::Fvschedule => "FVSCHEDULE".to_string(),
Function::Ipmt => functions.ipmt.clone(),
Function::Irr => functions.irr.clone(),
Function::Ispmt => functions.ispmt.clone(),
Expand Down Expand Up @@ -1168,7 +1170,7 @@ impl Function {
Function::Steyx => functions.steyx.clone(),
}
}
pub fn into_iter() -> IntoIter<Function, 345> {
pub fn into_iter() -> IntoIter<Function, 346> {
[
Function::And,
Function::False,
Expand Down Expand Up @@ -1338,6 +1340,7 @@ impl Function {
Function::Rate,
Function::Nper,
Function::Fv,
Function::Fvschedule,
Function::Ppmt,
Function::Ipmt,
Function::Npv,
Expand Down Expand Up @@ -1794,6 +1797,7 @@ impl<'a> Model<'a> {
Function::Rate => self.fn_rate(args, cell),
Function::Nper => self.fn_nper(args, cell),
Function::Fv => self.fn_fv(args, cell),
Function::Fvschedule => self.fn_fvschedule(args, cell),
Function::Ppmt => self.fn_ppmt(args, cell),
Function::Ipmt => self.fn_ipmt(args, cell),
Function::Npv => self.fn_npv(args, cell),
Expand Down
1 change: 1 addition & 0 deletions base/src/test/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ mod test_extend;
mod test_floor;
mod test_fn_datevalue_timevalue;
mod test_fn_fv;
mod test_fn_fvschedule;
mod test_fn_round;
mod test_fn_type;
mod test_frozen_rows_and_columns;
Expand Down
23 changes: 23 additions & 0 deletions base/src/test/test_fn_financial.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ fn fn_arguments() {
model._set("E2", "=RATE(1,1)");
model._set("E3", "=RATE(1,1,1,1,1,1)");

model._set("F1", "=FVSCHEDULE()");
model._set("F2", "=FVSCHEDULE(1)");
model._set("F3", "=FVSCHEDULE(1,1,1)");

model.evaluate();

assert_eq!(model._get_text("A1"), *"#ERROR!");
Expand All @@ -46,6 +50,10 @@ fn fn_arguments() {
assert_eq!(model._get_text("E1"), *"#ERROR!");
assert_eq!(model._get_text("E2"), *"#ERROR!");
assert_eq!(model._get_text("E3"), *"#ERROR!");

assert_eq!(model._get_text("F1"), *"#ERROR!");
assert_eq!(model._get_text("F2"), *"#ERROR!");
assert_eq!(model._get_text("F3"), *"#ERROR!");
}

#[test]
Expand Down Expand Up @@ -468,3 +476,18 @@ fn fn_db_misc() {

assert_eq!(model._get_text("B1"), "$0.00");
}

#[test]
fn fn_fvschedule() {
let mut model = new_empty_model();
model._set("A1", "1000");
model._set("A2", "0.08");
model._set("A3", "0.09");
model._set("A4", "0.1");

model._set("B1", "=FVSCHEDULE(A1, A2:A4)");

model.evaluate();

assert_eq!(model._get_text("B1"), "1294.92");
}
127 changes: 127 additions & 0 deletions base/src/test/test_fn_fvschedule.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
#![allow(clippy::unwrap_used)]

use crate::{cell::CellValue, test::util::new_empty_model};

#[test]
fn computation() {
let mut model = new_empty_model();
model._set("B1", "0.1");
model._set("B2", "0.2");
model._set("A1", "=FVSCHEDULE(100,B1:B2)");

model.evaluate();

assert_eq!(model._get_text("A1"), "132");
}

#[test]
fn fvschedule_basic_with_precise_assertion() {
let mut model = new_empty_model();
model._set("A1", "1000");
model._set("B1", "0.09");
model._set("B2", "0.11");
model._set("B3", "0.1");

model._set("C1", "=FVSCHEDULE(A1,B1:B3)");
model.evaluate();

assert_eq!(
model.get_cell_value_by_ref("Sheet1!C1"),
Ok(CellValue::Number(1330.89))
);
}

#[test]
fn fvschedule_compound_rates() {
let mut model = new_empty_model();
model._set("A1", "1");
model._set("A2", "0.1");
model._set("A3", "0.2");
model._set("A4", "0.3");

model._set("B1", "=FVSCHEDULE(A1, A2:A4)");

model.evaluate();

// 1 * (1+0.1) * (1+0.2) * (1+0.3) = 1 * 1.1 * 1.2 * 1.3 = 1.716
assert_eq!(model._get_text("B1"), "1.716");
}

#[test]
fn fvschedule_ignore_non_numbers() {
let mut model = new_empty_model();
model._set("A1", "1");
model._set("A2", "0.1");
model._set("A3", "foo"); // non-numeric value should be ignored
model._set("A4", "0.2");

model._set("B1", "=FVSCHEDULE(A1, A2:A4)");

model.evaluate();

// 1 * (1+0.1) * (1+0.2) = 1 * 1.1 * 1.2 = 1.32
assert_eq!(model._get_text("B1"), "1.32");
}

#[test]
fn fvschedule_argument_count() {
let mut model = new_empty_model();
model._set("A1", "=FVSCHEDULE()");
model._set("A2", "=FVSCHEDULE(1)");
model._set("A3", "=FVSCHEDULE(1,1,1)");

model.evaluate();

assert_eq!(model._get_text("A1"), *"#ERROR!");
assert_eq!(model._get_text("A2"), *"#ERROR!");
assert_eq!(model._get_text("A3"), *"#ERROR!");
}

#[test]
fn fvschedule_edge_cases() {
let mut model = new_empty_model();

// Test with zero principal
model._set("A1", "0");
model._set("A2", "0.1");
model._set("A3", "0.2");
model._set("B1", "=FVSCHEDULE(A1, A2:A3)");

// Test with negative principal
model._set("C1", "-100");
model._set("D1", "=FVSCHEDULE(C1, A2:A3)");

// Test with zero rates
model._set("E1", "100");
model._set("E2", "0");
model._set("E3", "0");
model._set("F1", "=FVSCHEDULE(E1, E2:E3)");

model.evaluate();

assert_eq!(model._get_text("B1"), "0"); // 0 * anything = 0
assert_eq!(model._get_text("D1"), "-132"); // -100 * 1.1 * 1.2 = -132
assert_eq!(model._get_text("F1"), "100"); // 100 * 1 * 1 = 100
}

#[test]
fn fvschedule_rate_validation() {
let mut model = new_empty_model();

// Test with rate exactly -1 (should cause error due to validation in patch 1)
model._set("A1", "100");
model._set("A2", "-1");
model._set("A3", "0.1");
model._set("B1", "=FVSCHEDULE(A1, A2:A3)");

// Test with rate less than -1 (should cause error)
model._set("C1", "100");
model._set("C2", "-1.5");
model._set("C3", "0.1");
model._set("D1", "=FVSCHEDULE(C1, C2:C3)");

model.evaluate();

assert_eq!(model._get_text("B1"), "#NUM!");
assert_eq!(model._get_text("D1"), "#NUM!");
}
2 changes: 1 addition & 1 deletion docs/src/functions/financial.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir
| DURATION | <Badge type="info" text="Not implemented yet" /> | – |
| EFFECT | <Badge type="tip" text="Available" /> | – |
| FV | <Badge type="tip" text="Available" /> | [FV](financial/fv) |
| FVSCHEDULE | <Badge type="info" text="Not implemented yet" /> | |
| FVSCHEDULE | <Badge type="tip" text="Available" /> | [FVSCHEDULE](financial/fvschedule) |
| INTRATE | <Badge type="info" text="Not implemented yet" /> | – |
| IPMT | <Badge type="tip" text="Available" /> | – |
| IRR | <Badge type="tip" text="Available" /> | – |
Expand Down
3 changes: 1 addition & 2 deletions docs/src/functions/financial/fvschedule.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,5 @@ lang: en-US
# FVSCHEDULE

::: warning
🚧 This function is not yet available in IronCalc.
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb).
:::