-
- clearance date is before the posting date which is incorrect.")
- }} />
+
+
{data && data.message.result.length > 0 &&
- ${formattedToDate}`, `${formattedToDate}`])
- }} />
+
{_("You can reset the clearing dates of these entries here.")}
}
-
+
{error &&
}
diff --git a/banking/src/components/features/BankReconciliation/Rules/RuleForm.tsx b/banking/src/components/features/BankReconciliation/Rules/RuleForm.tsx
index 1655de9ec83..5a638c46985 100644
--- a/banking/src/components/features/BankReconciliation/Rules/RuleForm.tsx
+++ b/banking/src/components/features/BankReconciliation/Rules/RuleForm.tsx
@@ -11,6 +11,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { H4, Paragraph } from "@/components/ui/typography"
import { today } from "@/lib/date"
+import { evaluateAmountFormula } from "@/lib/amountFormula"
import _ from "@/lib/translate"
import { cn } from "@/lib/utils"
import { BankTransactionRule } from "@/types/Accounts/BankTransactionRule"
@@ -445,11 +446,10 @@ const AmountFormulaRenderer = ({ value }: { value?: string }) => {
// If it's a string and cannot be a number, then show it as a formula
if (isNaN(Number(value))) {
-
let calculatedValue = "";
try {
- calculatedValue = window.eval(`const transaction_amount = 200; ${value}`);
+ calculatedValue = String(evaluateAmountFormula(value ?? "", 200));
} catch (error: unknown) {
console.error(error);
calculatedValue = "Error";
diff --git a/banking/src/lib/amountFormula.ts b/banking/src/lib/amountFormula.ts
new file mode 100644
index 00000000000..07860fd2064
--- /dev/null
+++ b/banking/src/lib/amountFormula.ts
@@ -0,0 +1,26 @@
+import { Parser } from 'safe-expr-eval'
+
+const parser = new Parser()
+
+const PLAIN_NUMBER_PATTERN = /^-?\d+(\.\d+)?$/
+
+export function evaluateAmountFormula(expression: string, transactionAmount: number): number {
+ const trimmed = expression.trim()
+ if (!trimmed) {
+ return 0
+ }
+
+ if (PLAIN_NUMBER_PATTERN.test(trimmed)) {
+ return Number(trimmed)
+ }
+
+ try {
+ const result = parser.parse(trimmed).evaluate({ transaction_amount: transactionAmount })
+ if (typeof result !== 'number' || !Number.isFinite(result)) {
+ return 0
+ }
+ return result
+ } catch {
+ return 0
+ }
+}
diff --git a/banking/yarn.lock b/banking/yarn.lock
index abd7e449837..08ba0fdf146 100644
--- a/banking/yarn.lock
+++ b/banking/yarn.lock
@@ -3406,6 +3406,11 @@ rolldown@~1.1.3:
"@rolldown/binding-win32-arm64-msvc" "1.1.3"
"@rolldown/binding-win32-x64-msvc" "1.1.3"
+safe-expr-eval@^1.0.4:
+ version "1.0.4"
+ resolved "https://registry.yarnpkg.com/safe-expr-eval/-/safe-expr-eval-1.0.4.tgz#3694739b3eb42b906417b94a5e6a74f3c21041b5"
+ integrity sha512-p6ILL9oYItu8Dqm7atFCq3JXW/S1AcZDXQMHRhkRIGpF96SIwEpzogjVNM87mCg+6rfz3Q//rwn1zH2EW2TQ/Q==
+
scheduler@^0.27.0:
version "0.27.0"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.27.0.tgz#0c4ef82d67d1e5c1e359e8fc76d3a87f045fe5bd"
diff --git a/erpnext/accounts/doctype/bank_transaction_rule/bank_transaction_rule.py b/erpnext/accounts/doctype/bank_transaction_rule/bank_transaction_rule.py
index 945428a8f39..24f84935d2a 100644
--- a/erpnext/accounts/doctype/bank_transaction_rule/bank_transaction_rule.py
+++ b/erpnext/accounts/doctype/bank_transaction_rule/bank_transaction_rule.py
@@ -9,6 +9,48 @@ from frappe.model.document import Document
from erpnext.accounts.doctype.bank_transaction.bank_transaction import BankTransaction
+PLAIN_NUMBER_PATTERN = re.compile(r"^-?\d+(\.\d+)?$")
+# Tokens accepted by safe-expr-eval on the frontend (must stay in sync).
+ALLOWED_FORMULA_TOKEN = re.compile(r"\s+|transaction_amount|\d+(?:\.\d+)?|[+\-*/%^()]")
+PYTHON_ONLY_OPERATORS = ("**", "//")
+
+
+def _is_expr_eval_formula(formula: str) -> bool:
+ position = 0
+ while position < len(formula):
+ match = ALLOWED_FORMULA_TOKEN.match(formula, position)
+ if not match:
+ return False
+ position = match.end()
+
+ return formula.count("(") == formula.count(")")
+
+
+def validate_amount_formula(formula: str) -> None:
+ if not formula:
+ return
+
+ stripped = formula.strip()
+ if PLAIN_NUMBER_PATTERN.match(stripped):
+ return
+
+ if any(operator in stripped for operator in PYTHON_ONLY_OPERATORS):
+ frappe.throw(_("Invalid debit/credit formula: {0}").format(formula))
+
+ if not _is_expr_eval_formula(stripped):
+ frappe.throw(_("Invalid debit/credit formula: {0}").format(formula))
+
+ # expr-eval uses ^ for exponentiation; translate for a smoke-test evaluation only.
+ python_formula = stripped.replace("^", "**")
+
+ try:
+ result = frappe.safe_eval(python_formula, eval_globals=None, eval_locals={"transaction_amount": 1})
+ except Exception:
+ frappe.throw(_("Invalid debit/credit formula: {0}").format(formula))
+
+ if not isinstance(result, (int | float)):
+ frappe.throw(_("Invalid debit/credit formula: {0}").format(formula))
+
class BankTransactionRule(Document):
# begin: auto-generated types
@@ -86,6 +128,11 @@ class BankTransactionRule(Document):
frappe.throw(
_("The last account row must not have any debit or credit amounts set.")
)
+ else:
+ if account.debit:
+ validate_amount_formula(account.debit)
+ if account.credit:
+ validate_amount_formula(account.credit)
# Validate regex
for rule in self.description_rules:
diff --git a/erpnext/accounts/doctype/bank_transaction_rule/test_bank_transaction_rule.py b/erpnext/accounts/doctype/bank_transaction_rule/test_bank_transaction_rule.py
index 4cb7e266c39..b57d03c8121 100644
--- a/erpnext/accounts/doctype/bank_transaction_rule/test_bank_transaction_rule.py
+++ b/erpnext/accounts/doctype/bank_transaction_rule/test_bank_transaction_rule.py
@@ -231,3 +231,45 @@ class TestBankTransactionRule(ERPNextTestSuite, AccountsTestMixin):
doc = self._rule("bad_rx", [{"check": "Regex", "value": "["}])
with self.assertRaises(ValidationError):
doc.insert()
+
+ def _multiple_accounts_rule(self, prefix: str, accounts, **fields):
+ return self._rule(
+ prefix,
+ [{"check": "Contains", "value": "x"}],
+ classify_as="Bank Entry",
+ bank_entry_type="Multiple Accounts",
+ accounts=accounts,
+ **fields,
+ )
+
+ def test_validate_bank_entry_multiple_valid_amount_formulas(self):
+ doc = self._multiple_accounts_rule(
+ "be_formula",
+ accounts=[
+ {"account": self.bank, "debit": "200", "credit": ""},
+ {"account": self.cash, "debit": "", "credit": "transaction_amount * 0.25"},
+ {"account": self.cash, "debit": "", "credit": ""},
+ ],
+ )
+ doc.insert()
+ self.assertTrue(doc.name)
+
+ def test_validate_bank_entry_multiple_invalid_amount_formulas(self):
+ malicious_formulas = [
+ "__import__('os')",
+ "eval('1+1')",
+ "open('/etc/passwd')",
+ "transaction_amount ** 2",
+ "transaction_amount // 2",
+ ]
+ for formula in malicious_formulas:
+ with self.subTest(formula=formula):
+ doc = self._multiple_accounts_rule(
+ "be_bad_formula",
+ accounts=[
+ {"account": self.bank, "debit": formula, "credit": ""},
+ {"account": self.cash, "debit": "", "credit": ""},
+ ],
+ )
+ with self.assertRaises(ValidationError):
+ doc.insert()