mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-27 07:17:06 +00:00
Compare commits
20 Commits
assets-ver
...
codex/seri
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
653ef6eff2 | ||
|
|
acbefcd603 | ||
|
|
445a30ba60 | ||
|
|
fca005c935 | ||
|
|
e51c01628d | ||
|
|
766e51ae58 | ||
|
|
3acaa55db9 | ||
|
|
dbfec6e9fb | ||
|
|
1426a098f1 | ||
|
|
92b6d708d8 | ||
|
|
fa244a3615 | ||
|
|
cdb12ecf9d | ||
|
|
86489d6905 | ||
|
|
d81fe03776 | ||
|
|
f80cac927d | ||
|
|
687c7d55ba | ||
|
|
6f2cf3bf91 | ||
|
|
48818c963a | ||
|
|
c67a57d9bd | ||
|
|
ca880f6be7 |
7
.github/POSTGRES_COMPATIBILITY.md
vendored
7
.github/POSTGRES_COMPATIBILITY.md
vendored
@@ -180,13 +180,6 @@ audit of these fixes found four recurring mistakes:
|
||||
the arbitrary-pick preservation the wrap is usually justified as. Confirmed on CI; see #56241.
|
||||
Note a local macOS PostgreSQL gives a **false all-clear** — its collation happens to agree with
|
||||
MariaDB on case. Fix: take a representative row rather than sorting text.
|
||||
**Picking that row is the hard part.** `Min(name)` is still a text sort: `autoname="hash"` is
|
||||
not reliably lower case, because `_get_timestamp_prefix()` prepends `get_trace_id()[-1:]`
|
||||
un-lowered and a client-supplied `X-Frappe-Request-Id` can put an upper case `A-F` there. A
|
||||
non-text key (`Min(idx)`) works only where it is **unique within the group** and the join-back
|
||||
carries the **full group key** — a date is usually neither, and joining on a duplicated value
|
||||
turns one group into several rows (§3). Otherwise select the row in Python, sorting with
|
||||
`key=str.casefold` so the order matches MariaDB's collation without depending on the database's.
|
||||
- **Wrong bound** — where the value has a semantic, pick the bound deliberately:
|
||||
`Min(schedule_date)` for a "required by", `Min(idx)` for first-line ordering, a qty-weighted
|
||||
average for a rate. A blind `Max` can understate urgency or overstate a figure.
|
||||
|
||||
13
.github/workflows/linters.yml
vendored
13
.github/workflows/linters.yml
vendored
@@ -23,19 +23,6 @@ jobs:
|
||||
- name: Install and Run Pre-commit
|
||||
uses: pre-commit/action@v3.0.1
|
||||
|
||||
js-unit-tests:
|
||||
name: js unit tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Run JS unit tests
|
||||
run: yarn test:js
|
||||
|
||||
semgrep:
|
||||
name: semgrep
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -22,6 +22,6 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: alyf-de/po-review-action@57fff275f4a0518a2ca55869ec6776fa3813b3d5 # v1.2.0
|
||||
- uses: alyf-de/po-review-action@5928f84d6bc9094f9ad6e2c5780f01c0044b800e # v1.1.1
|
||||
with:
|
||||
hidden-po-files: eo.po
|
||||
|
||||
@@ -47,29 +47,14 @@ class ERPNextAddress(Address):
|
||||
super().on_update()
|
||||
|
||||
address_display = get_address_display(self.as_dict())
|
||||
customers = frappe.db.get_all(
|
||||
"Customer", filters={"customer_primary_address": self.name}, pluck="name"
|
||||
)
|
||||
for customer in customers:
|
||||
frappe.db.set_value(
|
||||
"Customer", customer, "primary_address", address_display, update_modified=False
|
||||
)
|
||||
filters = {"customer_primary_address": self.name}
|
||||
customers = frappe.db.get_all("Customer", filters=filters, as_list=True)
|
||||
for customer_name in customers:
|
||||
frappe.db.set_value("Customer", customer_name[0], "primary_address", address_display)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_shipping_address(company: str, address: str | None = None):
|
||||
# `company` is caller supplied and this returns that company's own registered address with every
|
||||
# field. `select` rather than `read` on Company: Delivery, Maintenance, Purchase Manager and
|
||||
# Stock Manager all fill in transactions that ask for this while holding no Company `read` row.
|
||||
frappe.has_permission("Company", ptype="select", throw=True)
|
||||
|
||||
# and scope it to the caller's own Company restrictions, which costs nobody who has none
|
||||
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_companies
|
||||
|
||||
allowed_companies = get_allowed_companies(frappe.session.user, "Address")
|
||||
if allowed_companies and company not in allowed_companies:
|
||||
frappe.throw(_("Not permitted for {0}").format(company), frappe.PermissionError)
|
||||
|
||||
filters = [
|
||||
["Dynamic Link", "link_doctype", "=", "Company"],
|
||||
["Dynamic Link", "link_name", "=", company],
|
||||
|
||||
@@ -24,7 +24,7 @@ def get(
|
||||
heatmap_year: str | None = None,
|
||||
):
|
||||
if chart_name:
|
||||
chart = frappe.get_doc("Dashboard Chart", chart_name, check_permission="read")
|
||||
chart = frappe.get_doc("Dashboard Chart", chart_name)
|
||||
else:
|
||||
chart = frappe._dict(frappe.parse_json(chart))
|
||||
timespan = chart.timespan
|
||||
@@ -46,9 +46,6 @@ def get(
|
||||
if not account:
|
||||
frappe.throw(_("Account filter not set!"))
|
||||
|
||||
# authorise the account itself, as get_balance_on() does; doc= brings User Permissions with it
|
||||
frappe.has_permission("Account", doc=account, throw=True)
|
||||
|
||||
if not to_date:
|
||||
to_date = nowdate()
|
||||
if not from_date:
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
"description": "Heads (or groups) against which Accounting Entries are made and balances are maintained.",
|
||||
"doctype": "DocType",
|
||||
"document_type": "Setup",
|
||||
"documentation": "https://docs.frappe.io/erpnext/chart-of-accounts",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"properties",
|
||||
@@ -201,7 +200,7 @@
|
||||
"options": "Account Category"
|
||||
}
|
||||
],
|
||||
"icon": "vault",
|
||||
"icon": "fa fa-money",
|
||||
"idx": 1,
|
||||
"is_tree": 1,
|
||||
"links": [],
|
||||
|
||||
@@ -503,21 +503,24 @@ class Account(NestedSet):
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_parent_account(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
|
||||
return frappe.get_list(
|
||||
"Account",
|
||||
filters=[
|
||||
["is_group", "=", 1],
|
||||
["docstatus", "!=", 2],
|
||||
["company", "=", filters["company"]],
|
||||
[searchfield, "like", f"%{txt}%"],
|
||||
],
|
||||
fields=["name"],
|
||||
order_by="name",
|
||||
limit_start=start,
|
||||
limit_page_length=page_len,
|
||||
as_list=True,
|
||||
Account = frappe.qb.DocType("Account")
|
||||
|
||||
search_field_obj = getattr(Account, searchfield)
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(Account)
|
||||
.select(Account.name)
|
||||
.where(Account.is_group == 1)
|
||||
.where(Account.docstatus != 2)
|
||||
.where(Account.company == filters["company"])
|
||||
.where(search_field_obj.like(f"%{txt}%"))
|
||||
.order_by(Account.name)
|
||||
.limit(page_len)
|
||||
.offset(start)
|
||||
)
|
||||
|
||||
return query.run(as_list=1)
|
||||
|
||||
|
||||
def get_account_currency(account):
|
||||
"""Helper function to get account currency"""
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"icon": "folder-tree",
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [
|
||||
{
|
||||
|
||||
@@ -148,7 +148,7 @@
|
||||
"precision": "9"
|
||||
}
|
||||
],
|
||||
"icon": "scale",
|
||||
"icon": "fa fa-list",
|
||||
"in_create": 1,
|
||||
"links": [],
|
||||
"modified": "2025-08-22 19:13:50.400404",
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"autoname": "field:label",
|
||||
"creation": "2019-05-04 18:13:37.002352",
|
||||
"doctype": "DocType",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/accounting-dimensions",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"document_type",
|
||||
@@ -50,7 +49,6 @@
|
||||
"options": "Accounting Dimension Detail"
|
||||
}
|
||||
],
|
||||
"icon": "layers",
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:05:56.890002",
|
||||
"modified_by": "Administrator",
|
||||
|
||||
@@ -223,11 +223,8 @@ def delete_accounting_dimension(doc):
|
||||
frappe.clear_cache(doctype=doctype)
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def disable_dimension(doc: str):
|
||||
# toggle_disabling rewrites a Custom Field site-wide, so demand the write that configures dimensions
|
||||
frappe.has_permission("Accounting Dimension", "write", throw=True)
|
||||
|
||||
if frappe.in_test:
|
||||
toggle_disabling(doc=doc)
|
||||
else:
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"autoname": "format:{accounting_dimension}-{#####}",
|
||||
"creation": "2020-11-08 18:28:11.906146",
|
||||
"doctype": "DocType",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/accounting-dimension-filter",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
@@ -100,7 +99,6 @@
|
||||
"label": "Fieldname"
|
||||
}
|
||||
],
|
||||
"icon": "funnel",
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-08-08 14:13:22.203011",
|
||||
|
||||
@@ -12,7 +12,7 @@ frappe.ui.form.on("Accounting Period", {
|
||||
doc: frm.doc,
|
||||
callback: function (r) {
|
||||
if (r.message) {
|
||||
frm.clear_table("closed_documents");
|
||||
cur_frm.clear_table("closed_documents");
|
||||
r.message.forEach(function (element) {
|
||||
var c = frm.add_child("closed_documents");
|
||||
c.document_type = element.document_type;
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"autoname": "field:period_name",
|
||||
"creation": "2018-04-13 18:50:14.672323",
|
||||
"doctype": "DocType",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/accounting-period",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
@@ -78,7 +77,6 @@
|
||||
"options": "Role"
|
||||
}
|
||||
],
|
||||
"icon": "calendar-range",
|
||||
"links": [],
|
||||
"modified": "2026-03-09 17:15:33.577217",
|
||||
"modified_by": "Administrator",
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"creation": "2013-06-24 15:49:57",
|
||||
"doctype": "DocType",
|
||||
"document_type": "Other",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/accounts-settings",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
@@ -802,7 +801,7 @@
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"icon": "settings",
|
||||
"icon": "icon-cog",
|
||||
"idx": 1,
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
|
||||
@@ -222,13 +222,6 @@ class AccountsSettings(Document):
|
||||
set_allow_on_submit_for_dimension_fields(doctypes)
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def get_posting_date_confirmation() -> int:
|
||||
return cint(
|
||||
frappe.db.get_single_value("Accounts Settings", "confirm_before_resetting_posting_date", cache=False)
|
||||
)
|
||||
|
||||
|
||||
def toggle_accounting_dimension_sections(hide):
|
||||
accounting_dimension_doctypes = frappe.get_hooks("accounting_dimension_doctypes")
|
||||
for doctype in accounting_dimension_doctypes:
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.accounts_settings.accounts_settings import get_posting_date_confirmation
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestAccountsSettings(ERPNextTestSuite):
|
||||
def test_posting_date_confirmation_uses_current_setting(self):
|
||||
for enabled in (0, 1, 0):
|
||||
frappe.db.set_single_value("Accounts Settings", "confirm_before_resetting_posting_date", enabled)
|
||||
self.assertEqual(get_posting_date_confirmation(), enabled)
|
||||
|
||||
def test_stale_days(self):
|
||||
cur_settings = frappe.get_doc("Accounts Settings", "Accounts Settings")
|
||||
cur_settings.allow_stale = 0
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"creation": "2024-10-16 16:57:12.085072",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"icon": "book-open",
|
||||
"is_submittable": 1,
|
||||
"field_order": [
|
||||
"company",
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
"creation": "2018-04-07 16:59:59.496668",
|
||||
"doctype": "DocType",
|
||||
"document_type": "Setup",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/bank",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
@@ -101,7 +100,6 @@
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"icon": "landmark",
|
||||
"links": [],
|
||||
"modified": "2026-08-21 23:11:39.423431",
|
||||
"modified_by": "Administrator",
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"creation": "2017-05-29 21:35:13.136357",
|
||||
"doctype": "DocType",
|
||||
"document_type": "Setup",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/bank-account",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"account_name",
|
||||
@@ -228,7 +227,6 @@
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"icon": "credit-card",
|
||||
"links": [
|
||||
{
|
||||
"group": "Transactions",
|
||||
|
||||
@@ -44,7 +44,6 @@
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"icon": "wallet",
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-16 22:17:48.007982",
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"unique": 1
|
||||
}
|
||||
],
|
||||
"icon": "credit-card",
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:06:37.221876",
|
||||
"modified_by": "Administrator",
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"unique": 1
|
||||
}
|
||||
],
|
||||
"icon": "credit-card",
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:06:37.347035",
|
||||
"modified_by": "Administrator",
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
}
|
||||
],
|
||||
"hide_toolbar": 1,
|
||||
"icon": "badge-check",
|
||||
"icon": "fa fa-check",
|
||||
"idx": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
cur_frm.add_fetch("bank_account", "account", "account");
|
||||
cur_frm.add_fetch("bank_account", "bank_account_no", "bank_account_no");
|
||||
cur_frm.add_fetch("bank_account", "iban", "iban");
|
||||
cur_frm.add_fetch("bank_account", "branch_code", "branch_code");
|
||||
cur_frm.add_fetch("bank", "swift_number", "swift_number");
|
||||
|
||||
frappe.ui.form.on("Bank Guarantee", {
|
||||
setup: function (frm) {
|
||||
frm.add_fetch("bank_account", "account", "account");
|
||||
frm.add_fetch("bank_account", "bank_account_no", "bank_account_no");
|
||||
frm.add_fetch("bank_account", "iban", "iban");
|
||||
frm.add_fetch("bank_account", "branch_code", "branch_code");
|
||||
frm.add_fetch("bank", "swift_number", "swift_number");
|
||||
|
||||
frm.set_query("reference_doctype", function () {
|
||||
return {
|
||||
filters: {
|
||||
@@ -63,15 +63,11 @@ frappe.ui.form.on("Bank Guarantee", {
|
||||
},
|
||||
|
||||
start_date: function (frm) {
|
||||
frm.events.set_end_date(frm);
|
||||
var end_date = frappe.datetime.add_days(cur_frm.doc.start_date, cur_frm.doc.validity - 1);
|
||||
cur_frm.set_value("end_date", end_date);
|
||||
},
|
||||
|
||||
validity: function (frm) {
|
||||
frm.events.set_end_date(frm);
|
||||
},
|
||||
|
||||
set_end_date: function (frm) {
|
||||
let end_date = frappe.datetime.add_days(frm.doc.start_date, frm.doc.validity - 1);
|
||||
frm.set_value("end_date", end_date);
|
||||
var end_date = frappe.datetime.add_days(cur_frm.doc.start_date, cur_frm.doc.validity - 1);
|
||||
cur_frm.set_value("end_date", end_date);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"creation": "2016-12-17 10:43:35.731631",
|
||||
"doctype": "DocType",
|
||||
"document_type": "Document",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/bank-guarantee",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
@@ -217,7 +216,6 @@
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"icon": "shield-check",
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-25 18:12:10.768835",
|
||||
|
||||
@@ -60,9 +60,6 @@ def get_voucher_details(bank_guarantee_type: str, reference_name: str):
|
||||
if not isinstance(reference_name, str):
|
||||
raise TypeError("reference_name must be a string")
|
||||
|
||||
# the form is the boundary, not the referenced order: an order guard would break one of the two roles
|
||||
frappe.has_permission("Bank Guarantee", throw=True)
|
||||
|
||||
fields_to_fetch = ["grand_total"]
|
||||
|
||||
if bank_guarantee_type == "Receiving":
|
||||
@@ -73,14 +70,4 @@ def get_voucher_details(bank_guarantee_type: str, reference_name: str):
|
||||
doctype = "Purchase Order"
|
||||
fields_to_fetch.append("supplier")
|
||||
|
||||
# and scope the referenced order to the caller's own Company restrictions, which costs nobody
|
||||
# who has none
|
||||
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_companies
|
||||
|
||||
allowed_companies = get_allowed_companies(frappe.session.user, "Bank Guarantee")
|
||||
if allowed_companies:
|
||||
company = frappe.db.get_value(doctype, reference_name, "company")
|
||||
if company and company not in allowed_companies:
|
||||
frappe.throw(_("Not permitted for {0}").format(company), frappe.PermissionError)
|
||||
|
||||
return frappe.db.get_value(doctype, reference_name, fields_to_fetch, as_dict=True)
|
||||
|
||||
@@ -68,7 +68,6 @@ frappe.ui.form.on("Bank Reconciliation Tool", {
|
||||
frappe.msgprint(__("Please select Bank Account"));
|
||||
return;
|
||||
}
|
||||
frm.events.validate_dates(frm);
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.auto_reconcile_vouchers",
|
||||
args: {
|
||||
@@ -83,7 +82,7 @@ frappe.ui.form.on("Bank Reconciliation Tool", {
|
||||
});
|
||||
|
||||
frm.add_custom_button(__("Get Unreconciled Entries"), function () {
|
||||
return frm.trigger("make_reconciliation_tool");
|
||||
frm.trigger("make_reconciliation_tool");
|
||||
});
|
||||
frm.change_custom_button_type(__("Get Unreconciled Entries"), null, "primary");
|
||||
|
||||
@@ -107,24 +106,7 @@ frappe.ui.form.on("Bank Reconciliation Tool", {
|
||||
frm.trigger("get_account_opening_balance");
|
||||
},
|
||||
|
||||
validate_dates(frm) {
|
||||
const from_date = frm.doc.filter_by_reference_date
|
||||
? frm.doc.from_reference_date
|
||||
: frm.doc.bank_statement_from_date;
|
||||
const to_date = frm.doc.filter_by_reference_date
|
||||
? frm.doc.to_reference_date
|
||||
: frm.doc.bank_statement_to_date;
|
||||
if (from_date && to_date && from_date > to_date) {
|
||||
frappe.throw(
|
||||
frm.doc.filter_by_reference_date
|
||||
? __("From Reference Date cannot be greater than To Reference Date")
|
||||
: __("From Date cannot be greater than To Date")
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
make_reconciliation_tool(frm) {
|
||||
frm.events.validate_dates(frm);
|
||||
frm.get_field("reconciliation_tool_cards").$wrapper.empty();
|
||||
if (frm.doc.company && frm.doc.bank_account && frm.doc.bank_statement_to_date) {
|
||||
frm.trigger("get_cleared_balance").then(() => {
|
||||
|
||||
@@ -116,7 +116,6 @@
|
||||
}
|
||||
],
|
||||
"hide_toolbar": 1,
|
||||
"icon": "arrow-left-right",
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
|
||||
@@ -9,7 +9,7 @@ from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.query_builder.custom import ConstantColumn
|
||||
from frappe.query_builder.functions import Max, Sum
|
||||
from frappe.utils import cint, create_batch, flt, getdate
|
||||
from frappe.utils import cint, create_batch, flt
|
||||
|
||||
from erpnext import get_default_cost_center
|
||||
from erpnext.accounts.doctype.bank_transaction.bank_transaction import get_total_allocated_amount
|
||||
@@ -54,8 +54,6 @@ def get_bank_transactions(
|
||||
all_transactions: bool = False,
|
||||
):
|
||||
# returns bank transactions for a bank account
|
||||
validate_date_range(from_date, to_date)
|
||||
|
||||
filters = []
|
||||
filters.append(["bank_account", "=", bank_account])
|
||||
filters.append(["docstatus", "=", 1])
|
||||
@@ -792,7 +790,6 @@ def create_bulk_payment_entry_and_reconcile(
|
||||
"deposit",
|
||||
"withdrawal",
|
||||
"bank_account",
|
||||
"company",
|
||||
"currency",
|
||||
"unallocated_amount",
|
||||
"date",
|
||||
@@ -827,7 +824,11 @@ def create_bulk_payment_entry_and_reconcile(
|
||||
"paid_from": paid_from,
|
||||
"paid_to": paid_to,
|
||||
"paid_amount": bank_transaction.unallocated_amount,
|
||||
"base_paid_amount": bank_transaction.unallocated_amount,
|
||||
"received_amount": bank_transaction.unallocated_amount,
|
||||
"base_received_amount": bank_transaction.unallocated_amount,
|
||||
"target_exchange_rate": 1,
|
||||
"source_exchange_rate": 1,
|
||||
"reference_date": bank_transaction.date,
|
||||
"posting_date": bank_transaction.date,
|
||||
"reference_no": (bank_transaction.reference_number or bank_transaction.description or "")[
|
||||
@@ -836,8 +837,6 @@ def create_bulk_payment_entry_and_reconcile(
|
||||
}
|
||||
)
|
||||
|
||||
set_multi_currency_amounts(payment_entry_doc)
|
||||
|
||||
payment_entry_doc.insert()
|
||||
payment_entry_doc.submit()
|
||||
|
||||
@@ -876,7 +875,6 @@ def create_payment_entry_and_reconcile(bank_transaction_name: str | int, payment
|
||||
"doctype": "Payment Entry",
|
||||
}
|
||||
)
|
||||
set_multi_currency_amounts(payment_entry)
|
||||
payment_entry.insert()
|
||||
payment_entry.submit()
|
||||
transaction = reconcile_vouchers(
|
||||
@@ -899,33 +897,6 @@ def create_payment_entry_and_reconcile(bank_transaction_name: str | int, payment
|
||||
}
|
||||
|
||||
|
||||
def set_multi_currency_amounts(pe):
|
||||
"""Set real exchange rates when the bank and party accounts differ in currency."""
|
||||
company_currency = frappe.get_cached_value("Company", pe.company, "default_currency")
|
||||
pe.paid_from_account_currency = frappe.get_cached_value("Account", pe.paid_from, "account_currency")
|
||||
pe.paid_to_account_currency = frappe.get_cached_value("Account", pe.paid_to, "account_currency")
|
||||
|
||||
pe.source_exchange_rate = (
|
||||
1.0
|
||||
if pe.paid_from_account_currency == company_currency
|
||||
else get_exchange_rate(pe.paid_from_account_currency, company_currency, pe.posting_date)
|
||||
)
|
||||
pe.target_exchange_rate = (
|
||||
1.0
|
||||
if pe.paid_to_account_currency == company_currency
|
||||
else get_exchange_rate(pe.paid_to_account_currency, company_currency, pe.posting_date)
|
||||
)
|
||||
|
||||
# derive the party-side amount from the authoritative bank-side amount; Payment Entry books any
|
||||
# rounding residual to Exchange Gain/Loss during validation (set_exchange_gain_loss)
|
||||
if pe.payment_type == "Receive" and pe.source_exchange_rate:
|
||||
base_amount = flt(pe.received_amount) * pe.target_exchange_rate
|
||||
pe.paid_amount = flt(base_amount / pe.source_exchange_rate, pe.precision("paid_amount"))
|
||||
elif pe.payment_type == "Pay" and pe.target_exchange_rate:
|
||||
base_amount = flt(pe.paid_amount) * pe.source_exchange_rate
|
||||
pe.received_amount = flt(base_amount / pe.target_exchange_rate, pe.precision("received_amount"))
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["GET"])
|
||||
def search_for_transfer_transaction(transaction_id: str | int):
|
||||
"""
|
||||
@@ -991,10 +962,9 @@ def auto_reconcile_vouchers(
|
||||
from_date: str | date | None = None,
|
||||
to_date: str | date | None = None,
|
||||
filter_by_reference_date: bool | None = None,
|
||||
from_reference_date: str | date | None = None,
|
||||
to_reference_date: str | date | None = None,
|
||||
from_reference_date: bool | None = None,
|
||||
to_reference_date: str | None = None,
|
||||
):
|
||||
validate_date_range(from_date, to_date, filter_by_reference_date, from_reference_date, to_reference_date)
|
||||
bank_transactions = get_bank_transactions(bank_account)
|
||||
|
||||
if len(bank_transactions) > 10:
|
||||
@@ -1109,11 +1079,10 @@ def get_linked_payments(
|
||||
from_date: str | date | None = None,
|
||||
to_date: str | date | None = None,
|
||||
filter_by_reference_date: bool | None = None,
|
||||
from_reference_date: str | date | None = None,
|
||||
to_reference_date: str | date | None = None,
|
||||
from_reference_date: bool | None = None,
|
||||
to_reference_date: str | None = None,
|
||||
):
|
||||
# get all matching payments for a bank transaction
|
||||
validate_date_range(from_date, to_date, filter_by_reference_date, from_reference_date, to_reference_date)
|
||||
transaction = frappe.get_doc("Bank Transaction", bank_transaction_name)
|
||||
bank_account = frappe.db.get_values(
|
||||
"Bank Account", transaction.bank_account, ["account", "company"], as_dict=True
|
||||
@@ -1133,23 +1102,6 @@ def get_linked_payments(
|
||||
return subtract_allocations(gl_account, matching)
|
||||
|
||||
|
||||
def validate_date_range(
|
||||
from_date,
|
||||
to_date,
|
||||
filter_by_reference_date=False,
|
||||
from_reference_date=None,
|
||||
to_reference_date=None,
|
||||
):
|
||||
if cint(filter_by_reference_date):
|
||||
from_date, to_date = from_reference_date, to_reference_date
|
||||
message = _("From Reference Date cannot be greater than To Reference Date")
|
||||
else:
|
||||
message = _("From Date cannot be greater than To Date")
|
||||
|
||||
if from_date and to_date and getdate(from_date) > getdate(to_date):
|
||||
frappe.throw(message)
|
||||
|
||||
|
||||
def subtract_allocations(gl_account, vouchers):
|
||||
"Look up & subtract any existing Bank Transaction allocations"
|
||||
copied = []
|
||||
@@ -1186,7 +1138,6 @@ def check_matching(
|
||||
from_reference_date=None,
|
||||
to_reference_date=None,
|
||||
):
|
||||
document_types = document_types or []
|
||||
exact_match = True if "exact_match" in document_types else False
|
||||
|
||||
common_filters = frappe._dict(
|
||||
|
||||
@@ -2,16 +2,12 @@
|
||||
# See license.txt
|
||||
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import frappe
|
||||
from frappe import qb
|
||||
from frappe.utils import add_days, today
|
||||
|
||||
from erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool import (
|
||||
auto_reconcile_vouchers,
|
||||
create_bulk_payment_entry_and_reconcile,
|
||||
create_payment_entry_and_reconcile,
|
||||
get_auto_reconcile_message,
|
||||
get_bank_transactions,
|
||||
get_linked_payments,
|
||||
@@ -20,8 +16,6 @@ from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_pay
|
||||
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
RATE_METHOD = "erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_exchange_rate"
|
||||
|
||||
|
||||
class TestBankReconciliationTool(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
@@ -137,37 +131,6 @@ class TestBankReconciliationTool(ERPNextTestSuite, AccountsTestMixin):
|
||||
names = [t.name for t in get_bank_transactions(self.bank_account, to_date=add_days(today(), -1))]
|
||||
self.assertEqual(names, [])
|
||||
|
||||
def test_get_linked_payments_without_document_types(self):
|
||||
bank_transaction = self.make_bank_transaction(date=today())
|
||||
self.assertEqual(get_linked_payments(bank_transaction.name), [])
|
||||
|
||||
def test_rejects_reversed_date_ranges(self):
|
||||
from_date, to_date = today(), add_days(today(), -1)
|
||||
with self.assertRaisesRegex(frappe.ValidationError, "From Date cannot be greater than To Date"):
|
||||
get_bank_transactions(self.bank_account, from_date, to_date)
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
frappe.ValidationError, "From Reference Date cannot be greater than To Reference Date"
|
||||
):
|
||||
auto_reconcile_vouchers(
|
||||
self.bank_account,
|
||||
filter_by_reference_date=True,
|
||||
from_reference_date=from_date,
|
||||
to_reference_date=to_date,
|
||||
)
|
||||
|
||||
transaction = self.make_bank_transaction(date=today())
|
||||
with self.assertRaisesRegex(
|
||||
frappe.ValidationError, "From Reference Date cannot be greater than To Reference Date"
|
||||
):
|
||||
get_linked_payments(
|
||||
transaction.name,
|
||||
["payment_entry"],
|
||||
filter_by_reference_date=True,
|
||||
from_reference_date=from_date,
|
||||
to_reference_date=to_date,
|
||||
)
|
||||
|
||||
def test_deposit_matches_amount_received_in_bank_account(self):
|
||||
# money leaves another bank account and lands here minus a charge, so the two sides differ
|
||||
payment = frappe.get_doc(
|
||||
@@ -236,117 +199,3 @@ class TestBankReconciliationTool(ERPNextTestSuite, AccountsTestMixin):
|
||||
self.assertIn("1 Transaction Partially Reconciled", singular)
|
||||
plural, _ = get_auto_reconcile_message(["p1", "p2"], [])
|
||||
self.assertIn("2 Transactions Partially Reconciled", plural)
|
||||
|
||||
def test_multi_currency_pay_converts_and_balances(self):
|
||||
# withdrawal from an INR bank paying a USD supplier; rate 3.0 makes 100/3 non-exact
|
||||
self.enable_multi_currency_setup()
|
||||
pe = self.reconcile_new_payment(
|
||||
self.make_multi_currency_txn(withdrawal=100),
|
||||
payment_type="Pay",
|
||||
party_type="Supplier",
|
||||
party=self.supplier,
|
||||
party_account=self.creditors_usd,
|
||||
paid_from=self.bank,
|
||||
paid_to=self.creditors_usd,
|
||||
rate=3.0,
|
||||
)
|
||||
self.assertEqual(pe.docstatus, 1) # submits despite the rounding residual
|
||||
self.assertEqual((pe.source_exchange_rate, pe.target_exchange_rate), (1.0, 3.0))
|
||||
self.assertEqual((pe.paid_amount, pe.received_amount), (100, 33.33)) # bank side kept, 100/3
|
||||
self.assertEqual(pe.difference_amount, 0)
|
||||
# Payment Entry auto-books the rounding residual to Exchange Gain/Loss
|
||||
self.assertTrue(pe.deductions[0].is_exchange_gain_loss)
|
||||
self.assertEqual(pe.deductions[0].amount, 0.01) # 100 - 33.33 * 3
|
||||
|
||||
def test_multi_currency_receive_converts_and_balances(self):
|
||||
# deposit into an INR bank from a USD customer; the party side must convert
|
||||
self.enable_multi_currency_setup()
|
||||
pe = self.reconcile_new_payment(
|
||||
self.make_multi_currency_txn(deposit=100),
|
||||
payment_type="Receive",
|
||||
party_type="Customer",
|
||||
party=self.customer,
|
||||
party_account=self.debtors_usd,
|
||||
paid_from=self.debtors_usd,
|
||||
paid_to=self.bank,
|
||||
rate=3.0,
|
||||
)
|
||||
self.assertEqual(pe.docstatus, 1)
|
||||
self.assertEqual((pe.source_exchange_rate, pe.target_exchange_rate), (3.0, 1.0))
|
||||
self.assertEqual((pe.received_amount, pe.paid_amount), (100, 33.33)) # bank side kept, 100/3
|
||||
self.assertEqual(pe.difference_amount, 0)
|
||||
|
||||
def test_multi_currency_bulk_pay_converts_and_balances(self):
|
||||
# the bulk path builds the Payment Entry itself, so it must convert too
|
||||
self.enable_multi_currency_setup()
|
||||
txn = self.make_multi_currency_txn(withdrawal=100)
|
||||
with patch(RATE_METHOD, return_value=3.0):
|
||||
result = create_bulk_payment_entry_and_reconcile(
|
||||
[txn.name], "Supplier", self.supplier, self.creditors_usd
|
||||
)
|
||||
|
||||
pe = frappe.get_doc("Payment Entry", result[0]["payment_entry"].name)
|
||||
self.assertEqual(pe.docstatus, 1)
|
||||
self.assertEqual(pe.target_exchange_rate, 3.0)
|
||||
self.assertEqual((pe.paid_amount, pe.received_amount), (100, 33.33))
|
||||
self.assertEqual(pe.difference_amount, 0)
|
||||
|
||||
def enable_multi_currency_setup(self):
|
||||
# USD party/accounts + a company gain/loss account to absorb rounding residuals
|
||||
self.company_abbr = "_TC"
|
||||
self.create_supplier(supplier_name="_Test Supplier USD", currency="USD")
|
||||
self.create_customer(customer_name="_Test Customer USD", currency="USD")
|
||||
self.create_usd_payable_account()
|
||||
self.create_usd_receivable_account()
|
||||
self.set_party_account("Supplier", self.supplier, self.creditors_usd)
|
||||
if not frappe.db.get_value("Company", self.company, "exchange_gain_loss_account"):
|
||||
frappe.db.set_value(
|
||||
"Company", self.company, "exchange_gain_loss_account", "Exchange Gain/Loss - _TC"
|
||||
)
|
||||
|
||||
def set_party_account(self, party_type, party, account):
|
||||
doc = frappe.get_doc(party_type, party)
|
||||
if not any(row.company == self.company for row in doc.accounts):
|
||||
doc.append("accounts", {"company": self.company, "account": account})
|
||||
doc.save()
|
||||
|
||||
def make_multi_currency_txn(self, withdrawal=0, deposit=0):
|
||||
return (
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Bank Transaction",
|
||||
"date": today(),
|
||||
"withdrawal": withdrawal,
|
||||
"deposit": deposit,
|
||||
"bank_account": self.bank_account,
|
||||
"currency": "INR",
|
||||
"reference_number": "TEST-FX-REF",
|
||||
}
|
||||
)
|
||||
.save()
|
||||
.submit()
|
||||
)
|
||||
|
||||
def reconcile_new_payment(
|
||||
self, txn, *, payment_type, party_type, party, party_account, paid_from, paid_to, rate
|
||||
):
|
||||
# mimics the /banking frontend, which sends a hardcoded 1:1 rate
|
||||
payment_entry_doc = {
|
||||
"payment_type": payment_type,
|
||||
"company": self.company,
|
||||
"party_type": party_type,
|
||||
"party": party,
|
||||
"party_account": party_account,
|
||||
"paid_from": paid_from,
|
||||
"paid_to": paid_to,
|
||||
"paid_amount": txn.unallocated_amount,
|
||||
"received_amount": txn.unallocated_amount,
|
||||
"source_exchange_rate": 1,
|
||||
"target_exchange_rate": 1,
|
||||
"posting_date": today(),
|
||||
"reference_no": f"TEST-FX-{payment_type}",
|
||||
"reference_date": today(),
|
||||
}
|
||||
with patch(RATE_METHOD, return_value=rate):
|
||||
result = create_payment_entry_and_reconcile(txn.name, payment_entry_doc)
|
||||
return frappe.get_doc("Payment Entry", result["payment_entry"].name)
|
||||
|
||||
@@ -225,7 +225,6 @@
|
||||
}
|
||||
],
|
||||
"hide_toolbar": 1,
|
||||
"icon": "file-down",
|
||||
"links": [],
|
||||
"modified": "2026-06-19 14:18:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
|
||||
@@ -437,11 +437,6 @@ def get_import_logs(docname: str):
|
||||
|
||||
@frappe.whitelist()
|
||||
def upload_bank_statement(**args):
|
||||
# The only caller is the Bank Reconciliation Tool's "Upload Bank Statement" button, whose
|
||||
# callback routes straight into a new Bank Statement Import form — so `create` is exactly the
|
||||
# right to require, and both doctypes are System Manager only, which makes it loser-free.
|
||||
frappe.has_permission("Bank Statement Import", "create", throw=True)
|
||||
|
||||
args = frappe._dict(args)
|
||||
bsi = frappe.new_doc("Bank Statement Import")
|
||||
|
||||
|
||||
@@ -188,7 +188,6 @@
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"icon": "file-clock",
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-09 17:55:25.615942",
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"autoname": "naming_series:",
|
||||
"creation": "2018-10-22 18:19:02.784533",
|
||||
"doctype": "DocType",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/bank-transaction",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
@@ -278,7 +277,6 @@
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"icon": "arrow-left-right",
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-04-11 20:41:15.124085",
|
||||
|
||||
@@ -11,11 +11,6 @@ from frappe.utils.dateutils import parse_date
|
||||
|
||||
@frappe.whitelist()
|
||||
def upload_bank_statement():
|
||||
# Parsing a statement is the first step of creating Bank Transactions from it, so that is the
|
||||
# right to require. Both functions in this file are reached only over HTTP — nothing in the tree
|
||||
# calls either — so there is no caller to break.
|
||||
frappe.has_permission("Bank Transaction", "create", throw=True)
|
||||
|
||||
if getattr(frappe, "uploaded_file", None):
|
||||
with open(frappe.uploaded_file, "rb") as upfile:
|
||||
fcontent = upfile.read()
|
||||
@@ -41,12 +36,6 @@ def upload_bank_statement():
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def create_bank_entries(columns: str, data: str | list, bank_account: str):
|
||||
# insert()/submit() below already enforce this per document, but only after the per-row loop has
|
||||
# read the Bank Account and its Bank mapping and written an Error Log for every rejected row —
|
||||
# so check once up front rather than failing row by row.
|
||||
frappe.has_permission("Bank Transaction", "create", throw=True)
|
||||
frappe.has_permission("Bank Account", doc=bank_account, throw=True)
|
||||
|
||||
header_map = get_header_mapping(columns, bank_account)
|
||||
|
||||
success = 0
|
||||
|
||||
@@ -140,7 +140,6 @@
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"icon": "zap",
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2026-04-11 21:36:26.754667",
|
||||
|
||||
@@ -167,7 +167,6 @@
|
||||
}
|
||||
],
|
||||
"hide_toolbar": 1,
|
||||
"icon": "split",
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
"label": "Generated"
|
||||
}
|
||||
],
|
||||
"icon": "git-branch",
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:06:39.766063",
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"autoname": "naming_series:",
|
||||
"creation": "2016-05-16 11:42:29.632528",
|
||||
"doctype": "DocType",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/budgeting",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
@@ -331,7 +330,6 @@
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"icon": "piggy-bank",
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
|
||||
@@ -122,7 +122,6 @@
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"icon": "lock",
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:06:44.260440",
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"description": "Import Chart of Accounts from a csv file",
|
||||
"doctype": "DocType",
|
||||
"document_type": "Other",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/chart-of-accounts-importer",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
@@ -48,7 +47,6 @@
|
||||
}
|
||||
],
|
||||
"hide_toolbar": 1,
|
||||
"icon": "file-input",
|
||||
"in_create": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
|
||||
@@ -8,7 +8,6 @@ from functools import reduce
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.core.doctype.file.utils import find_file_by_url
|
||||
from frappe.desk.form.linked_with import get_linked_fields
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import cint, cstr
|
||||
@@ -59,8 +58,6 @@ def validate_columns(data):
|
||||
|
||||
@frappe.whitelist()
|
||||
def validate_company(company: str):
|
||||
frappe.has_permission("Chart of Accounts Importer", throw=True)
|
||||
|
||||
parent_company, allow_account_creation_against_child_company = frappe.get_cached_value(
|
||||
"Company", company, ["parent_company", "allow_account_creation_against_child_company"]
|
||||
)
|
||||
@@ -113,10 +110,7 @@ def import_coa(file_name: str, company: str):
|
||||
|
||||
|
||||
def get_file(file_name):
|
||||
file_doc = find_file_by_url(file_name)
|
||||
if not file_doc:
|
||||
raise frappe.PermissionError
|
||||
|
||||
file_doc = frappe.get_doc("File", {"file_url": file_name})
|
||||
parts = file_doc.get_extension()
|
||||
extension = parts[1]
|
||||
extension = extension.lstrip(".")
|
||||
@@ -185,8 +179,6 @@ def get_coa(
|
||||
):
|
||||
"""called by tree view (to fetch node's children)"""
|
||||
|
||||
frappe.has_permission("Chart of Accounts Importer", throw=True)
|
||||
|
||||
file_doc, extension = get_file(file_name)
|
||||
parent = None if parent == _("All Accounts") else parent
|
||||
|
||||
@@ -334,8 +326,6 @@ def build_response_as_excel(writer):
|
||||
|
||||
@frappe.whitelist()
|
||||
def download_template(file_type: str, template_type: str, company: str):
|
||||
frappe.has_permission("Chart of Accounts Importer", throw=True)
|
||||
|
||||
writer = get_template(template_type, company)
|
||||
|
||||
if file_type == "CSV":
|
||||
@@ -388,6 +378,7 @@ def get_sample_template(writer, company):
|
||||
return writer
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def validate_accounts(file_doc: Document, extension: str):
|
||||
if extension == "csv":
|
||||
accounts = generate_data_from_csv(file_doc, as_dict=True)
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"autoname": "field:bank_name",
|
||||
"creation": "2016-05-04 14:35:00.402544",
|
||||
"doctype": "DocType",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/cheque-print-template",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"settings",
|
||||
@@ -294,7 +293,6 @@
|
||||
"fieldtype": "HTML"
|
||||
}
|
||||
],
|
||||
"icon": "printer",
|
||||
"links": [],
|
||||
"max_attachments": 1,
|
||||
"modified": "2026-06-08 12:10:35.829531",
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
"description": "Track separate Income and Expense for product verticals or divisions.",
|
||||
"doctype": "DocType",
|
||||
"document_type": "Setup",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/cost-center",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"sb0",
|
||||
@@ -123,7 +122,7 @@
|
||||
"label": "Disabled"
|
||||
}
|
||||
],
|
||||
"icon": "chart-pie",
|
||||
"icon": "fa fa-money",
|
||||
"idx": 1,
|
||||
"is_tree": 1,
|
||||
"links": [],
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"autoname": "CC-ALLOC-.#####",
|
||||
"creation": "2022-01-13 20:07:29.871109",
|
||||
"doctype": "DocType",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/cost_center_allocation",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
@@ -66,7 +65,6 @@
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"icon": "chart-pie",
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
|
||||
@@ -5,10 +5,7 @@ frappe.ui.form.on("Coupon Code", {
|
||||
setup: function (frm) {
|
||||
frm.set_query("pricing_rule", function () {
|
||||
return {
|
||||
filters: {
|
||||
coupon_code_based: 1,
|
||||
disable: 0,
|
||||
},
|
||||
filters: [["Pricing Rule", "coupon_code_based", "=", "1"]],
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"creation": "2018-01-22 14:34:39.701832",
|
||||
"doctype": "DocType",
|
||||
"document_type": "Other",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/coupon-code",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
@@ -125,7 +124,6 @@
|
||||
"label": "From External Ecomm Platform"
|
||||
}
|
||||
],
|
||||
"icon": "ticket-percent",
|
||||
"links": [],
|
||||
"modified": "2026-08-21 23:11:41.010871",
|
||||
"modified_by": "Administrator",
|
||||
|
||||
@@ -42,23 +42,7 @@ class CouponCode(Document):
|
||||
self.coupon_code = frappe.generate_hash()[:10].upper()
|
||||
|
||||
def validate(self):
|
||||
self.validate_from_to_dates("valid_from", "valid_upto")
|
||||
self.validate_pricing_rule()
|
||||
|
||||
if self.coupon_type == "Gift Card":
|
||||
self.maximum_use = 1
|
||||
if not self.customer:
|
||||
frappe.throw(_("Please select the customer."))
|
||||
|
||||
def validate_pricing_rule(self):
|
||||
if not self.pricing_rule or self.from_external_ecomm_platform:
|
||||
return
|
||||
|
||||
# Allow existing coupons to be updated after their pricing rule is disabled.
|
||||
if not (
|
||||
self.has_value_changed("pricing_rule") or self.has_value_changed("from_external_ecomm_platform")
|
||||
):
|
||||
return
|
||||
|
||||
if frappe.db.get_value("Pricing Rule", self.pricing_rule, "disable"):
|
||||
frappe.throw(_("Pricing Rule {0} is disabled").format(frappe.bold(self.pricing_rule)))
|
||||
|
||||
@@ -112,43 +112,6 @@ class TestCouponCode(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
test_create_test_data()
|
||||
|
||||
def test_disabled_pricing_rule_validation(self):
|
||||
coupon = frappe.get_doc("Coupon Code", "SAVE30")
|
||||
rule = frappe.get_doc("Pricing Rule", coupon.pricing_rule)
|
||||
rule.disable = 1
|
||||
rule.save()
|
||||
|
||||
with self.subTest("new coupon cannot select a disabled rule"):
|
||||
new_coupon = frappe.copy_doc(coupon)
|
||||
new_coupon.coupon_name = "Festival Savings"
|
||||
new_coupon.coupon_code = "FESTSAVE"
|
||||
with self.assertRaisesRegex(frappe.ValidationError, "is disabled"):
|
||||
new_coupon.insert()
|
||||
|
||||
with self.subTest("existing coupon can retain a disabled rule"):
|
||||
coupon.description = "Offer paused"
|
||||
coupon.save()
|
||||
coupon.reload()
|
||||
self.assertEqual(coupon.description, "Offer paused")
|
||||
self.assertEqual(coupon.pricing_rule, rule.name)
|
||||
|
||||
with self.subTest("existing coupon cannot switch to a disabled rule"):
|
||||
disabled_rule = frappe.copy_doc(rule)
|
||||
disabled_rule.insert()
|
||||
coupon.reload()
|
||||
coupon.pricing_rule = disabled_rule.name
|
||||
with self.assertRaisesRegex(frappe.ValidationError, "is disabled"):
|
||||
coupon.save()
|
||||
coupon.reload()
|
||||
self.assertEqual(coupon.pricing_rule, rule.name)
|
||||
|
||||
def test_cannot_save_coupon_with_reversed_validity_dates(self):
|
||||
coupon = frappe.get_doc("Coupon Code", "SAVE30")
|
||||
coupon.valid_from = "2026-09-17"
|
||||
coupon.valid_upto = "2026-09-02"
|
||||
with self.assertRaises(frappe.exceptions.InvalidDates):
|
||||
coupon.save()
|
||||
|
||||
def test_sales_order_with_coupon_code(self):
|
||||
frappe.db.set_value("Coupon Code", "SAVE30", "used", 0)
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"actions": [],
|
||||
"creation": "2022-01-10 13:03:26.237081",
|
||||
"doctype": "DocType",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/currency-exchange-settings",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
@@ -102,7 +101,6 @@
|
||||
"label": "Use HTTP Protocol"
|
||||
}
|
||||
],
|
||||
"icon": "refresh-cw",
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"autoname": "naming_series:",
|
||||
"creation": "2019-07-05 16:34:31.013238",
|
||||
"doctype": "DocType",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/dunning",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"naming_series",
|
||||
@@ -399,7 +398,6 @@
|
||||
"fieldtype": "Column Break"
|
||||
}
|
||||
],
|
||||
"icon": "bell-ring",
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-30 23:18:04.712528",
|
||||
|
||||
@@ -17,8 +17,7 @@ import json
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.contacts.doctype.address.address import get_address_display
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import flt, getdate
|
||||
from frappe.utils import getdate
|
||||
|
||||
from erpnext.controllers.accounts_controller import AccountsController
|
||||
|
||||
@@ -148,31 +147,6 @@ class Dunning(AccountsController):
|
||||
)
|
||||
row.dunning_level = len(past_dunnings) + 1
|
||||
|
||||
def get_unpaid_base_dunning_amount(self):
|
||||
"""Interest and dunning fee that is still to be collected, in company currency."""
|
||||
if not self.base_dunning_amount:
|
||||
return 0.0
|
||||
|
||||
return flt(
|
||||
flt(self.base_dunning_amount) - get_paid_dunning_amount(self.name),
|
||||
self.precision("base_dunning_amount"),
|
||||
)
|
||||
|
||||
def get_unpaid_dunning_amount(self):
|
||||
"""Interest and dunning fee that is still to be collected, in the dunning currency."""
|
||||
return flt(
|
||||
self.get_unpaid_base_dunning_amount() / (flt(self.conversion_rate) or 1),
|
||||
self.precision("dunning_amount"),
|
||||
)
|
||||
|
||||
def get_unpaid_overdue_payments(self):
|
||||
"""Overdue payments with their outstanding as of now, not as of dunning creation."""
|
||||
return [
|
||||
(row, outstanding)
|
||||
for row in self.overdue_payments
|
||||
if (outstanding := get_current_outstanding(row)) > 0
|
||||
]
|
||||
|
||||
def on_cancel(self):
|
||||
super().on_cancel()
|
||||
self.ignore_linked_doctypes = [
|
||||
@@ -187,7 +161,6 @@ class Dunning(AccountsController):
|
||||
"Unreconcile Payment Entries",
|
||||
"Payment Ledger Entry",
|
||||
"Serial and Batch Bundle",
|
||||
"Payment Entry",
|
||||
]
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -286,73 +259,11 @@ def update_linked_dunnings(doc, previous_outstanding_amount):
|
||||
if has_outstanding:
|
||||
break
|
||||
|
||||
set_dunning_status(dunning, has_outstanding, respect_manual_resolution=True)
|
||||
new_status = "Resolved" if not has_outstanding else "Unresolved"
|
||||
|
||||
|
||||
def update_dunnings_linked_to_payment(payment_entry):
|
||||
"""Refresh dunnings whose interest and fee are settled by this payment."""
|
||||
dunnings = {row.dunning for row in payment_entry.get("deductions") if row.dunning}
|
||||
|
||||
for name in dunnings:
|
||||
dunning = frappe.get_doc("Dunning", name)
|
||||
if dunning.docstatus != 1:
|
||||
continue
|
||||
|
||||
set_dunning_status(dunning, bool(dunning.get_unpaid_overdue_payments()))
|
||||
|
||||
|
||||
def set_dunning_status(dunning, has_outstanding_payments: bool, respect_manual_resolution: bool = False):
|
||||
"""A dunning is only resolved once the invoiced sum *and* its interest and fee are paid."""
|
||||
has_unpaid_dunning_amount = dunning.get_unpaid_dunning_amount() > 0
|
||||
new_status = "Unresolved" if has_outstanding_payments or has_unpaid_dunning_amount else "Resolved"
|
||||
|
||||
# resolving by hand waives the interest, only an invoice that is owed again reopens it
|
||||
if respect_manual_resolution and dunning.status == "Resolved" and not has_outstanding_payments:
|
||||
return
|
||||
|
||||
if dunning.status != new_status:
|
||||
dunning.db_set("status", new_status, notify=True)
|
||||
|
||||
|
||||
def get_paid_dunning_amount(dunning: str) -> float:
|
||||
"""Interest and fee collected for this dunning, in company currency."""
|
||||
deduction = frappe.qb.DocType("Payment Entry Deduction")
|
||||
payment_entry = frappe.qb.DocType("Payment Entry")
|
||||
|
||||
paid = (
|
||||
frappe.qb.from_(deduction)
|
||||
.join(payment_entry)
|
||||
.on(payment_entry.name == deduction.parent)
|
||||
.select(Sum(deduction.amount))
|
||||
.where((deduction.dunning == dunning) & (payment_entry.docstatus == 1))
|
||||
).run()
|
||||
|
||||
# the dunning amount is booked as a negative deduction, against the income account
|
||||
return -flt(paid[0][0]) if paid else 0.0
|
||||
|
||||
|
||||
def get_current_outstanding(overdue_payment) -> float:
|
||||
"""Outstanding of an overdue payment as of now, in the invoice's transaction currency."""
|
||||
invoice = frappe.db.get_value(
|
||||
"Sales Invoice",
|
||||
overdue_payment.sales_invoice,
|
||||
["outstanding_amount", "currency", "party_account_currency"],
|
||||
as_dict=True,
|
||||
)
|
||||
schedule_outstanding = (
|
||||
flt(frappe.db.get_value("Payment Schedule", overdue_payment.payment_schedule, "outstanding"))
|
||||
if overdue_payment.payment_schedule
|
||||
else flt(overdue_payment.outstanding)
|
||||
)
|
||||
|
||||
if flt(invoice.outstanding_amount) <= 0 or schedule_outstanding <= 0:
|
||||
return 0.0
|
||||
|
||||
outstanding = min(schedule_outstanding, flt(overdue_payment.outstanding))
|
||||
if invoice.currency == invoice.party_account_currency:
|
||||
outstanding = min(outstanding, flt(invoice.outstanding_amount))
|
||||
|
||||
return outstanding
|
||||
if dunning.status != new_status:
|
||||
dunning.status = new_status
|
||||
dunning.save()
|
||||
|
||||
|
||||
def get_linked_dunnings_as_per_state(sales_invoice, state):
|
||||
|
||||
@@ -55,125 +55,6 @@ class TestDunning(ERPNextTestSuite):
|
||||
dunning.reload()
|
||||
self.assertEqual(dunning.status, "Resolved")
|
||||
|
||||
def test_dunning_not_resolved_by_payment_of_invoiced_sum_only(self):
|
||||
"""
|
||||
Regression for #58220: paying the invoice without the interest and fee must not
|
||||
resolve the dunning, the interest is still owed and has to stay claimable.
|
||||
"""
|
||||
dunning = create_dunning(overdue_days=15, dunning_type_name="Second Notice - _TC")
|
||||
dunning.submit()
|
||||
sales_invoice = dunning.overdue_payments[0].sales_invoice
|
||||
|
||||
pe = get_payment_entry("Sales Invoice", sales_invoice)
|
||||
pe.reference_no, pe.reference_date = "4", nowdate()
|
||||
pe.insert()
|
||||
pe.submit()
|
||||
|
||||
self.assertEqual(frappe.get_value("Sales Invoice", sales_invoice, "outstanding_amount"), 0)
|
||||
|
||||
dunning.reload()
|
||||
self.assertEqual(dunning.status, "Unresolved")
|
||||
self.assertEqual(round(dunning.get_unpaid_dunning_amount(), 2), 10.41)
|
||||
|
||||
# the interest and fee can still be collected on their own
|
||||
pe = get_payment_entry("Dunning", dunning.name)
|
||||
pe.reference_no, pe.reference_date = "5", nowdate()
|
||||
self.assertEqual(pe.references, [])
|
||||
self.assertEqual(round(pe.paid_amount, 2), 10.41)
|
||||
pe.insert()
|
||||
pe.submit()
|
||||
|
||||
dunning.reload()
|
||||
self.assertEqual(dunning.status, "Resolved")
|
||||
self.assertEqual(dunning.get_unpaid_dunning_amount(), 0)
|
||||
|
||||
# cancelling the interest payment makes the dunning claimable again
|
||||
pe.cancel()
|
||||
dunning.reload()
|
||||
self.assertEqual(dunning.status, "Unresolved")
|
||||
self.assertEqual(round(dunning.get_unpaid_dunning_amount(), 2), 10.41)
|
||||
|
||||
def test_dunning_can_be_cancelled_after_its_interest_was_paid(self):
|
||||
"""
|
||||
The payment collecting the interest links back to the dunning, which must not stand in
|
||||
the way of cancelling it.
|
||||
"""
|
||||
dunning = create_dunning(overdue_days=15, dunning_type_name="Second Notice - _TC")
|
||||
dunning.submit()
|
||||
|
||||
pe = get_payment_entry("Dunning", dunning.name)
|
||||
pe.reference_no, pe.reference_date = "6", nowdate()
|
||||
pe.insert()
|
||||
pe.submit()
|
||||
|
||||
dunning.reload()
|
||||
self.assertEqual(dunning.status, "Resolved")
|
||||
|
||||
dunning.cancel()
|
||||
self.assertEqual(dunning.docstatus, 2)
|
||||
|
||||
def test_waived_interest_keeps_a_manually_resolved_dunning_resolved(self):
|
||||
"""
|
||||
Resolving a dunning by hand waives its interest, so a later payment of the invoice
|
||||
must not reopen it.
|
||||
"""
|
||||
dunning = create_dunning(overdue_days=15, dunning_type_name="Second Notice - _TC")
|
||||
dunning.submit()
|
||||
sales_invoice = dunning.overdue_payments[0].sales_invoice
|
||||
|
||||
# what the "Resolve" button does
|
||||
dunning.reload()
|
||||
dunning.status = "Resolved"
|
||||
dunning.save()
|
||||
|
||||
pe = get_payment_entry("Sales Invoice", sales_invoice)
|
||||
pe.reference_no, pe.reference_date = "7", nowdate()
|
||||
pe.insert()
|
||||
pe.submit()
|
||||
|
||||
dunning.reload()
|
||||
self.assertEqual(dunning.status, "Resolved")
|
||||
self.assertEqual(round(dunning.get_unpaid_dunning_amount(), 2), 10.41)
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Accounts Settings", {"allow_multi_currency_invoices_against_single_party_account": 1}
|
||||
)
|
||||
def test_unpaid_dunning_amount_is_tracked_in_company_currency(self):
|
||||
"""
|
||||
The interest and fee are collected as a Payment Entry deduction, a company currency
|
||||
field, so what is left to collect has to be measured in the same currency.
|
||||
"""
|
||||
si = create_sales_invoice(
|
||||
posting_date=add_days(today(), -15),
|
||||
currency="USD",
|
||||
conversion_rate=50,
|
||||
rate=100,
|
||||
debit_to="Debtors - _TC",
|
||||
)
|
||||
|
||||
dunning = create_dunning_from_sales_invoice(si.name)
|
||||
dunning_type = frappe.get_doc("Dunning Type", "Second Notice - _TC")
|
||||
dunning.dunning_type = dunning_type.name
|
||||
dunning.rate_of_interest = dunning_type.rate_of_interest
|
||||
dunning.dunning_fee = dunning_type.dunning_fee
|
||||
dunning.income_account = dunning_type.income_account
|
||||
dunning.cost_center = dunning_type.cost_center
|
||||
dunning.save()
|
||||
|
||||
self.assertEqual(dunning.currency, "USD")
|
||||
self.assertEqual(dunning.conversion_rate, 50)
|
||||
self.assertEqual(round(dunning.dunning_amount, 2), 10.41)
|
||||
self.assertEqual(round(dunning.base_dunning_amount, 2), 520.55)
|
||||
|
||||
# nothing collected yet, in either currency
|
||||
self.assertEqual(round(dunning.get_unpaid_base_dunning_amount(), 2), 520.55)
|
||||
self.assertEqual(round(dunning.get_unpaid_dunning_amount(), 2), 10.41)
|
||||
|
||||
# the deduction booking the interest is in company currency
|
||||
dunning.submit()
|
||||
pe = get_payment_entry("Dunning", dunning.name)
|
||||
self.assertEqual(round(pe.deductions[0].amount, 2), -520.55)
|
||||
|
||||
def test_fetch_overdue_payments(self):
|
||||
"""
|
||||
Create SI with overdue payment. Check if overdue payment is fetched in Dunning.
|
||||
|
||||
@@ -101,7 +101,6 @@
|
||||
"fieldtype": "Column Break"
|
||||
}
|
||||
],
|
||||
"icon": "bell",
|
||||
"links": [
|
||||
{
|
||||
"link_doctype": "Dunning",
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"autoname": "ACC-ERR-.YYYY.-.#####",
|
||||
"creation": "2018-04-13 18:25:55.943587",
|
||||
"doctype": "DocType",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/exchange-rate-revaluation",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
@@ -108,7 +107,6 @@
|
||||
"precision": "9"
|
||||
}
|
||||
],
|
||||
"icon": "arrow-right-left",
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:09:42.951164",
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"creation": "2018-04-13 17:42:43.252224",
|
||||
"doctype": "DocType",
|
||||
"document_type": "Document",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/finance-book",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
@@ -19,7 +18,7 @@
|
||||
"unique": 1
|
||||
}
|
||||
],
|
||||
"icon": "book",
|
||||
"icon": "fa fa-book",
|
||||
"links": [],
|
||||
"modified": "2026-08-21 23:11:42.386104",
|
||||
"modified_by": "Administrator",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import ast
|
||||
import json
|
||||
import math
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from functools import cache, reduce
|
||||
@@ -28,7 +29,6 @@ from erpnext.accounts.doctype.financial_report_template.financial_report_templat
|
||||
FinancialReportTemplate,
|
||||
)
|
||||
from erpnext.accounts.doctype.financial_report_template.financial_report_validation import (
|
||||
FORMULA_FUNCTIONS,
|
||||
AccountFilterValidator,
|
||||
CalculationFormulaValidator,
|
||||
DependencyValidator,
|
||||
@@ -1328,14 +1328,26 @@ class FormulaCalculator:
|
||||
self.precision = get_currency_precision()
|
||||
self.validator = CalculationFormulaValidator(set(row_data.keys()))
|
||||
|
||||
self.math_functions = {
|
||||
"abs": abs,
|
||||
"round": round,
|
||||
"min": min,
|
||||
"max": max,
|
||||
"sum": sum,
|
||||
"sqrt": math.sqrt,
|
||||
"pow": math.pow,
|
||||
"ceil": math.ceil,
|
||||
"floor": math.floor,
|
||||
}
|
||||
|
||||
def evaluate_formula(self, report_row: dict[str, Any]) -> list[float]:
|
||||
validation_result = self.validator.validate(report_row)
|
||||
formula = (report_row.calculation_formula or "").strip()
|
||||
formula = report_row.calculation_formula
|
||||
negation_factor = -1 if report_row.reverse_sign else 1
|
||||
|
||||
if validation_result.issues:
|
||||
# TODO: Throw?
|
||||
messages = "<br><br>".join(str(issue) for issue in validation_result.issues)
|
||||
messages = "<br><br>".join(issue.message for issue in validation_result.issues)
|
||||
frappe.log_error(f"Formula validation errors found:\n{messages}")
|
||||
return [0.0] * len(self.period_list)
|
||||
|
||||
@@ -1350,7 +1362,7 @@ class FormulaCalculator:
|
||||
# TODO: consistent error handling
|
||||
try:
|
||||
context = self._build_context(period_index)
|
||||
result = frappe.safe_eval(formula, eval_globals=None, eval_locals=context)
|
||||
result = frappe.safe_eval(formula, context)
|
||||
return flt(result * negation_factor, self.precision)
|
||||
|
||||
except ZeroDivisionError:
|
||||
@@ -1371,7 +1383,7 @@ class FormulaCalculator:
|
||||
context[code] = 0.0
|
||||
|
||||
# math functions
|
||||
context.update(FORMULA_FUNCTIONS)
|
||||
context.update(self.math_functions)
|
||||
|
||||
return context
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"autoname": "field:template_name",
|
||||
"creation": "2025-08-02 04:44:15.184541",
|
||||
"doctype": "DocType",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/financial-report-template",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"template_name",
|
||||
@@ -65,7 +64,6 @@
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"icon": "file-spreadsheet",
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2026-02-23 01:04:05.797161",
|
||||
|
||||
@@ -34,13 +34,6 @@ class FinancialReportTemplate(Document):
|
||||
def before_validate(self):
|
||||
self.clear_hidden_fields()
|
||||
|
||||
for row in self.rows:
|
||||
if row.reference_code:
|
||||
row.reference_code = row.reference_code.strip()
|
||||
|
||||
if row.calculation_formula:
|
||||
row.calculation_formula = row.calculation_formula.strip()
|
||||
|
||||
def clear_hidden_fields(self):
|
||||
style_data_sources = {"Blank Line", "Column Break", "Section Break"}
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
# For license information, please see license.txt
|
||||
|
||||
import json
|
||||
import keyword
|
||||
import math
|
||||
import re
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
@@ -12,19 +10,6 @@ from typing import Any
|
||||
import frappe
|
||||
from frappe import _, is_whitelisted
|
||||
from frappe.database.operator_map import OPERATOR_MAP
|
||||
from frappe.utils import escape_html
|
||||
|
||||
FORMULA_FUNCTIONS = {
|
||||
"abs": abs,
|
||||
"round": round,
|
||||
"min": min,
|
||||
"max": max,
|
||||
"sum": sum,
|
||||
"sqrt": math.sqrt,
|
||||
"pow": math.pow,
|
||||
"ceil": math.ceil,
|
||||
"floor": math.floor,
|
||||
}
|
||||
|
||||
|
||||
def get_valid_api_method(api_path: str):
|
||||
@@ -104,9 +89,8 @@ class ValidationResult:
|
||||
self.warnings.append(issue)
|
||||
|
||||
def notify_user(self) -> None:
|
||||
# messages quote user input back, and both are rendered as HTML
|
||||
warnings = "<br><br>".join(escape_html(str(w)) for w in self.warnings if w)
|
||||
errors = "<br><br>".join(escape_html(str(e)) for e in self.issues if e)
|
||||
warnings = "<br><br>".join(str(w) for w in self.warnings if w)
|
||||
errors = "<br><br>".join(str(e) for e in self.issues if e)
|
||||
|
||||
if warnings:
|
||||
frappe.msgprint(warnings, title=_("Warnings"), indicator="orange")
|
||||
@@ -163,27 +147,18 @@ class TemplateStructureValidator(Validator):
|
||||
if not row.reference_code:
|
||||
continue
|
||||
|
||||
ref_code = row.reference_code
|
||||
ref_code = row.reference_code.strip()
|
||||
|
||||
# a line reference is used as a name in formulas, so it must be a usable one
|
||||
if not re.match(r"^[A-Za-z][A-Za-z0-9_]*$", ref_code):
|
||||
# Check format
|
||||
if not re.match(r"^[A-Za-z][A-Za-z0-9_-]*$", ref_code):
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=_(
|
||||
"Invalid line reference format: '{0}'. Must start with a letter and contain only letters, numbers and underscores"
|
||||
"Invalid line reference format: '{0}'. Must start with letter and contain only letters, numbers, underscores, and hyphens"
|
||||
).format(ref_code),
|
||||
row_idx=row.idx,
|
||||
)
|
||||
)
|
||||
elif keyword.iskeyword(ref_code) or ref_code in FORMULA_FUNCTIONS:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=_("'{0}' is a reserved name and cannot be used as a line reference").format(
|
||||
ref_code
|
||||
),
|
||||
row_idx=row.idx,
|
||||
)
|
||||
)
|
||||
|
||||
# Check uniqueness
|
||||
if ref_code in used_codes:
|
||||
@@ -233,7 +208,12 @@ class DependencyValidator(Validator):
|
||||
self.dependencies = self._build_dependency_graph()
|
||||
|
||||
def validate(self, context=None) -> ValidationResult:
|
||||
return self._validate_circular_dependencies()
|
||||
result = ValidationResult()
|
||||
|
||||
result.merge(self._validate_circular_dependencies())
|
||||
result.merge(self._validate_missing_dependencies())
|
||||
|
||||
return result
|
||||
|
||||
def _build_dependency_graph(self) -> dict[str, list[str]]:
|
||||
graph = {}
|
||||
@@ -300,6 +280,31 @@ class DependencyValidator(Validator):
|
||||
|
||||
return result
|
||||
|
||||
def _validate_missing_dependencies(self) -> ValidationResult:
|
||||
available = {row.reference_code for row in self.template.rows if row.reference_code}
|
||||
result = ValidationResult()
|
||||
|
||||
for ref_code, deps in self.dependencies.items():
|
||||
undefined = [d for d in deps if d not in available]
|
||||
if undefined:
|
||||
row_idx = self._get_row_idx(ref_code)
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=_("Line references undefined in {0}: {1}").format(
|
||||
get_formula_field_label("Calculated Amount"), ", ".join(undefined)
|
||||
),
|
||||
row_idx=row_idx,
|
||||
)
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def _get_row_idx(self, reference_code: str) -> int | None:
|
||||
for row in self.template.rows:
|
||||
if row.reference_code == reference_code:
|
||||
return row.idx
|
||||
return None
|
||||
|
||||
|
||||
class CalculationFormulaValidator(Validator):
|
||||
"""Validates calculation formulas used in Calculated Amount rows"""
|
||||
@@ -315,6 +320,7 @@ class CalculationFormulaValidator(Validator):
|
||||
return result
|
||||
|
||||
formula = self._preprocess_formula(row.calculation_formula)
|
||||
row.calculation_formula = formula
|
||||
|
||||
# Check parentheses
|
||||
if not self._are_parentheses_balanced(formula):
|
||||
@@ -362,15 +368,25 @@ class CalculationFormulaValidator(Validator):
|
||||
def _test_formula_evaluation(self, formula: str, available_codes: list[str]) -> str | None:
|
||||
try:
|
||||
context = {code: 1.0 for code in available_codes}
|
||||
context.update(FORMULA_FUNCTIONS)
|
||||
context.update(
|
||||
{
|
||||
"abs": abs,
|
||||
"round": round,
|
||||
"min": min,
|
||||
"max": max,
|
||||
"sum": sum,
|
||||
"sqrt": lambda x: x**0.5,
|
||||
"pow": pow,
|
||||
"ceil": lambda x: int(x) + (1 if x % 1 else 0),
|
||||
"floor": int,
|
||||
}
|
||||
)
|
||||
|
||||
result = frappe.safe_eval(formula, eval_globals=None, eval_locals=context)
|
||||
|
||||
if not isinstance(result, (int | float)):
|
||||
if not isinstance(result, (int, float)): # noqa: UP038
|
||||
return _("Formula must return a numeric value, got {0}").format(type(result).__name__)
|
||||
|
||||
return None
|
||||
except ZeroDivisionError:
|
||||
return None
|
||||
except Exception as e:
|
||||
return str(e)
|
||||
@@ -446,14 +462,13 @@ class AccountFilterValidator(Validator):
|
||||
return _("Field and operator must be strings")
|
||||
|
||||
if field not in account_fields:
|
||||
return _("Field '{0}' is not a valid Account field").format(field)
|
||||
# escape: `field` is caller-supplied and this message renders as HTML
|
||||
return _("Field '{0}' is not a valid Account field").format(frappe.utils.escape_html(field))
|
||||
|
||||
normalized_operator = operator.casefold()
|
||||
|
||||
if normalized_operator not in OPERATOR_MAP:
|
||||
if operator.casefold() not in OPERATOR_MAP:
|
||||
return _("Invalid operator '{0}'").format(operator)
|
||||
|
||||
if normalized_operator in ["in", "not in"] and not isinstance(value, list):
|
||||
if operator in ["in", "not in"] and not isinstance(value, list):
|
||||
return _("Operator '{0}' requires a list value").format(operator)
|
||||
|
||||
# logical condition: {"and": [condition1, condition2]}
|
||||
|
||||
@@ -5,11 +5,8 @@ import frappe
|
||||
from frappe.tests.utils import whitelist_for_tests
|
||||
|
||||
from erpnext.accounts.doctype.financial_report_template.financial_report_validation import (
|
||||
FORMULA_FUNCTIONS,
|
||||
AccountFilterValidator,
|
||||
CalculationFormulaValidator,
|
||||
FormulaValidator,
|
||||
TemplateStructureValidator,
|
||||
get_valid_api_method,
|
||||
)
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
@@ -192,13 +189,8 @@ class TestAccountFilter(FinancialReportTemplateTestCase):
|
||||
def test_error_message_labels_and_escapes_field(self):
|
||||
validator = AccountFilterValidator()
|
||||
result = validator.validate_filter(self._row('["<script>", "=", "x"]'))
|
||||
self.assertIn("[Account Filter]", str(result.issues[0]))
|
||||
|
||||
# escaping happens where the message is rendered, not where it is built
|
||||
frappe.clear_messages()
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
result.notify_user()
|
||||
message = frappe.get_message_log()[-1]["message"]
|
||||
message = str(result.issues[0])
|
||||
self.assertIn("[Account Filter]", message)
|
||||
self.assertIn("<script>", message)
|
||||
self.assertNotIn("<script>", message)
|
||||
|
||||
@@ -256,149 +248,3 @@ class TestAccountFilter(FinancialReportTemplateTestCase):
|
||||
pluck="name",
|
||||
)
|
||||
self.assertEqual(sorted(get_filtered_accounts(company, "[]")), sorted(expected))
|
||||
|
||||
|
||||
class TestFormulaEnvironment(FinancialReportTemplateTestCase):
|
||||
"""Validator and engine must evaluate a formula in the same environment."""
|
||||
|
||||
@staticmethod
|
||||
def _calc(row_data):
|
||||
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
|
||||
FormulaCalculator,
|
||||
)
|
||||
|
||||
return FormulaCalculator(row_data, [{"key": "p1"}])
|
||||
|
||||
@staticmethod
|
||||
def _row(formula):
|
||||
return frappe._dict(
|
||||
calculation_formula=formula,
|
||||
idx=1,
|
||||
reverse_sign=0,
|
||||
data_source="Calculated Amount",
|
||||
reference_code="X",
|
||||
)
|
||||
|
||||
def test_engine_keeps_reference_codes_named_like_builtins(self):
|
||||
# "int" and "long" are whitelisted safe_eval globals; the row values must win
|
||||
calc = self._calc({"int": [500.0], "long": [2000.0]})
|
||||
self.assertEqual(calc.evaluate_formula(self._row("int + long"))[0], 2500.0)
|
||||
|
||||
def test_validator_keeps_reference_codes_named_like_builtins(self):
|
||||
validator = CalculationFormulaValidator({"int", "long"})
|
||||
self.assertTrue(validator.validate(self._row("int + long")).is_valid)
|
||||
|
||||
def test_engine_uses_the_shared_function_list(self):
|
||||
context = self._calc({"A": [1.0]})._build_context(0)
|
||||
for name, function in FORMULA_FUNCTIONS.items():
|
||||
self.assertIs(context[name], function)
|
||||
|
||||
def test_rounding_matches_math_module(self):
|
||||
calc = self._calc({"A": [1.0]})
|
||||
self.assertEqual(calc.evaluate_formula(self._row("floor(-2.5)"))[0], -3.0)
|
||||
self.assertEqual(calc.evaluate_formula(self._row("ceil(-2.5)"))[0], -2.0)
|
||||
|
||||
|
||||
class TestCalculationFormula(FinancialReportTemplateTestCase):
|
||||
"""Formulas are test-evaluated with dummy values before a template can be saved."""
|
||||
|
||||
@staticmethod
|
||||
def _validate(formula, codes=("A", "B", "C")):
|
||||
row = frappe._dict(
|
||||
calculation_formula=formula, idx=1, data_source="Calculated Amount", reference_code="X"
|
||||
)
|
||||
return CalculationFormulaValidator(set(codes)).validate(row)
|
||||
|
||||
def test_division_by_zero_is_not_a_validation_error(self):
|
||||
# the dummy values are all 1.0, so a denominator can only be zero by accident;
|
||||
# the engine tolerates real division by zero at run time
|
||||
self.assertTrue(self._validate("A / (B - C)").is_valid)
|
||||
self.assertTrue(self._validate("(A - B) / (A - C)").is_valid)
|
||||
self.assertTrue(self._validate("ROM / (CAS + FDE - ROM)", ("ROM", "CAS", "FDE")).is_valid)
|
||||
self.assertTrue(self._validate("A / 0").is_valid)
|
||||
|
||||
def test_broken_formulas_are_rejected(self):
|
||||
self.assertFalse(self._validate("A +").is_valid)
|
||||
self.assertFalse(self._validate("NOPE * 2").is_valid)
|
||||
self.assertFalse(self._validate("'text'").is_valid)
|
||||
|
||||
|
||||
class TestFilterOperatorCase(FinancialReportTemplateTestCase):
|
||||
"""Operators are matched case-insensitively, so their value checks must be too."""
|
||||
|
||||
@staticmethod
|
||||
def _row(formula):
|
||||
return frappe._dict(calculation_formula=formula, idx=1)
|
||||
|
||||
def test_uppercase_in_requires_a_list_value(self):
|
||||
validator = AccountFilterValidator()
|
||||
self.assertFalse(validator.validate_filter(self._row('["root_type", "IN", "Income"]')).is_valid)
|
||||
self.assertFalse(validator.validate_filter(self._row('["root_type", "NOT IN", "Income"]')).is_valid)
|
||||
|
||||
def test_uppercase_in_accepts_a_list_value(self):
|
||||
validator = AccountFilterValidator()
|
||||
self.assertTrue(validator.validate_filter(self._row('["root_type", "IN", ["Income"]]')).is_valid)
|
||||
|
||||
|
||||
class TestLineReferenceNames(FinancialReportTemplateTestCase):
|
||||
"""A line reference becomes a name in formulas, so it must be usable as one."""
|
||||
|
||||
@staticmethod
|
||||
def _validate(code):
|
||||
template = frappe._dict(rows=[frappe._dict(reference_code=code, idx=1, data_source="Blank Line")])
|
||||
return TemplateStructureValidator()._validate_reference_codes(template)
|
||||
|
||||
def test_plain_codes_are_accepted(self):
|
||||
for code in ("REV", "CA100", "cash_flow_2"):
|
||||
self.assertTrue(self._validate(code).is_valid, code)
|
||||
|
||||
def test_hyphen_is_rejected(self):
|
||||
# "-" reads as subtraction in a formula and is not a valid Python name
|
||||
self.assertFalse(self._validate("REV-COGS").is_valid)
|
||||
|
||||
def test_python_keyword_is_rejected(self):
|
||||
for code in ("if", "None", "class"):
|
||||
self.assertFalse(self._validate(code).is_valid, code)
|
||||
|
||||
def test_formula_function_name_is_rejected(self):
|
||||
# these would be overwritten by the function of the same name
|
||||
for code in ("sum", "round", "abs"):
|
||||
self.assertFalse(self._validate(code).is_valid, code)
|
||||
|
||||
def test_surrounding_spaces_are_normalised_before_validation(self):
|
||||
template = frappe.new_doc("Financial Report Template")
|
||||
template.template_name = "Spaces"
|
||||
template.append("rows", {"reference_code": " REV ", "data_source": "Blank Line"})
|
||||
template.append(
|
||||
"rows",
|
||||
{
|
||||
"reference_code": "X",
|
||||
"data_source": "Calculated Amount",
|
||||
"calculation_formula": " REV * 2 ",
|
||||
},
|
||||
)
|
||||
template.before_validate()
|
||||
self.assertEqual(template.rows[0].reference_code, "REV")
|
||||
self.assertEqual(template.rows[1].calculation_formula, "REV * 2")
|
||||
|
||||
def test_validation_does_not_modify_the_row(self):
|
||||
row = frappe._dict(
|
||||
calculation_formula=" REV * 2 ",
|
||||
idx=1,
|
||||
data_source="Calculated Amount",
|
||||
reference_code="X",
|
||||
)
|
||||
CalculationFormulaValidator({"REV", "X"}).validate(row)
|
||||
self.assertEqual(row.calculation_formula, " REV * 2 ")
|
||||
|
||||
def test_invalid_reference_code_is_escaped(self):
|
||||
# this message fires when the code fails the format check, so it can hold anything
|
||||
template = frappe._dict(rows=[frappe._dict(reference_code="<img src=x onerror=alert(1)>", idx=1)])
|
||||
result = TemplateStructureValidator()._validate_reference_codes(template)
|
||||
|
||||
frappe.clear_messages()
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
result.notify_user()
|
||||
message = frappe.get_message_log()[-1]["message"]
|
||||
self.assertIn("<img", message)
|
||||
self.assertNotIn("<img", message)
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
"description": "Represents a Financial Year. All accounting entries and other major transactions are tracked against the Fiscal Year.",
|
||||
"doctype": "DocType",
|
||||
"document_type": "Setup",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/fiscal-year",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"year",
|
||||
@@ -80,7 +79,7 @@
|
||||
"set_only_once": 1
|
||||
}
|
||||
],
|
||||
"icon": "calendar",
|
||||
"icon": "fa fa-calendar",
|
||||
"idx": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-21 23:11:42.509102",
|
||||
|
||||
@@ -377,7 +377,7 @@
|
||||
"precision": "9"
|
||||
}
|
||||
],
|
||||
"icon": "book-open",
|
||||
"icon": "fa fa-list",
|
||||
"idx": 1,
|
||||
"in_create": 1,
|
||||
"links": [],
|
||||
|
||||
@@ -136,7 +136,6 @@ frappe.ui.form.on("Invoice Discounting", {
|
||||
],
|
||||
primary_action: function () {
|
||||
var data = d.get_values();
|
||||
data.company = frm.doc.company;
|
||||
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.invoice_discounting.invoice_discounting.get_invoices",
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"allow_import": 1,
|
||||
"autoname": "ACC-INV-DISC-.YYYY.-.#####",
|
||||
"creation": "2019-03-07 12:01:56.296952",
|
||||
"doctype": "DocType",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/invoice_discounting",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
@@ -170,10 +168,9 @@
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"icon": "ticket-percent",
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-09-09 17:04:59.512294",
|
||||
"modified": "2024-03-27 13:09:52.746196",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Invoice Discounting",
|
||||
@@ -190,15 +187,14 @@
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Accounts Manager",
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"submit": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
@@ -319,13 +319,6 @@ class InvoiceDiscounting(AccountsController):
|
||||
@frappe.whitelist()
|
||||
def get_invoices(filters: str | dict):
|
||||
filters = frappe._dict(frappe.parse_json(filters))
|
||||
|
||||
if not filters.get("company"):
|
||||
frappe.throw(_("Please set company on the Document before requesting for invoices."))
|
||||
|
||||
frappe.has_permission("Company", doc=filters.get("company"), throw=True)
|
||||
frappe.has_permission("Invoice Discounting", throw=True)
|
||||
|
||||
si = frappe.qb.DocType("Sales Invoice")
|
||||
di = frappe.qb.DocType("Discounted Invoice")
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"creation": "2022-01-19 01:09:13.297137",
|
||||
"doctype": "DocType",
|
||||
"document_type": "Setup",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/item-tax-template",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
@@ -57,7 +56,6 @@
|
||||
"fieldtype": "Section Break"
|
||||
}
|
||||
],
|
||||
"icon": "circle-percent",
|
||||
"links": [],
|
||||
"modified": "2026-08-21 23:11:43.571355",
|
||||
"modified_by": "Administrator",
|
||||
|
||||
@@ -32,7 +32,7 @@ frappe.ui.form.on("Journal Entry", {
|
||||
erpnext.journal_entry.lock_reversal_entry(frm);
|
||||
}
|
||||
|
||||
erpnext.toggle_naming_series(frm);
|
||||
erpnext.toggle_naming_series();
|
||||
erpnext.journal_entry.add_custom_buttons(frm);
|
||||
erpnext.journal_entry.toggle_fields_based_on_currency(frm);
|
||||
erpnext.accounts.unreconcile_payment.add_unreconcile_btn(frm);
|
||||
@@ -624,8 +624,8 @@ Object.assign(erpnext.journal_entry, {
|
||||
total_credit += flt(row.credit, precision("credit", row));
|
||||
});
|
||||
|
||||
frm.doc.total_debit = flt(total_debit, precision("total_debit"));
|
||||
frm.doc.total_credit = flt(total_credit, precision("total_credit"));
|
||||
frm.doc.total_debit = total_debit;
|
||||
frm.doc.total_credit = total_credit;
|
||||
frm.doc.difference = flt(total_debit - total_credit, precision("difference"));
|
||||
["total_debit", "total_credit", "difference"].forEach((field) => frm.refresh_field(field));
|
||||
},
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
"creation": "2022-01-25 10:29:58.717206",
|
||||
"doctype": "DocType",
|
||||
"document_type": "Document",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/journal-entry",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"entry_type_and_date",
|
||||
@@ -662,7 +661,7 @@
|
||||
"label": "Custom Remark"
|
||||
}
|
||||
],
|
||||
"icon": "notebook-pen",
|
||||
"icon": "fa fa-file-text",
|
||||
"idx": 176,
|
||||
"is_submittable": 1,
|
||||
"links": [
|
||||
|
||||
@@ -674,14 +674,12 @@ class JournalEntry(AccountsController):
|
||||
if d.debit and d.credit:
|
||||
frappe.throw(_("You cannot credit and debit same account at the same time"))
|
||||
|
||||
self.total_debit = flt(
|
||||
self.total_debit + flt(d.debit, d.precision("debit")), self.precision("total_debit")
|
||||
)
|
||||
self.total_credit = flt(
|
||||
self.total_credit + flt(d.credit, d.precision("credit")), self.precision("total_credit")
|
||||
)
|
||||
self.total_debit = flt(self.total_debit) + flt(d.debit, d.precision("debit"))
|
||||
self.total_credit = flt(self.total_credit) + flt(d.credit, d.precision("credit"))
|
||||
|
||||
self.difference = flt(self.total_debit - self.total_credit, self.precision("difference"))
|
||||
self.difference = flt(self.total_debit, self.precision("total_debit")) - flt(
|
||||
self.total_credit, self.precision("total_credit")
|
||||
)
|
||||
|
||||
def validate_multi_currency(self):
|
||||
alternate_currency = []
|
||||
@@ -1023,11 +1021,6 @@ def get_default_bank_cash_account(
|
||||
) -> dict:
|
||||
from erpnext.accounts.doctype.sales_invoice.sales_invoice import get_bank_cash_account
|
||||
|
||||
# the company is the scope being authorised, and doc= brings User Permissions to bear. `select`,
|
||||
# not `read`: this also runs server-side from get_payment_entry, and Auditor/HR User/Desk User
|
||||
# hold only the select row on Company
|
||||
frappe.has_permission("Company", ptype="select", doc=company, throw=True)
|
||||
|
||||
if mode_of_payment:
|
||||
account = get_bank_cash_account(mode_of_payment, company).get("account")
|
||||
|
||||
@@ -1056,11 +1049,6 @@ def get_default_bank_cash_account(
|
||||
account = account_list[0].name
|
||||
|
||||
if account:
|
||||
# `account` may be named by the caller outright, so authorise the account actually being
|
||||
# described. get_balance_on() checks this too, but only on the branch that reads a balance,
|
||||
# and `fetch_balance` is a caller-supplied argument.
|
||||
frappe.has_permission("Account", doc=account, throw=True)
|
||||
|
||||
account_details = frappe.get_cached_value(
|
||||
"Account", account, ["account_currency", "account_type"], as_dict=1
|
||||
)
|
||||
@@ -1090,40 +1078,30 @@ def get_against_jv(
|
||||
if not frappe.db.has_column("Journal Entry", searchfield):
|
||||
return []
|
||||
|
||||
account = filters.get("account")
|
||||
JournalEntry = frappe.qb.DocType("Journal Entry")
|
||||
JournalEntryAccount = frappe.qb.DocType("Journal Entry Account")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(JournalEntry)
|
||||
.join(JournalEntryAccount)
|
||||
.on(JournalEntryAccount.parent == JournalEntry.name)
|
||||
.select(JournalEntry.name, JournalEntry.posting_date, JournalEntry.remark)
|
||||
.where(JournalEntryAccount.account == filters.get("account"))
|
||||
.where(JournalEntryAccount.reference_type.isnull() | (JournalEntryAccount.reference_type == ""))
|
||||
.where(JournalEntry.docstatus == 1)
|
||||
.where(JournalEntry[searchfield].like(f"%{txt}%"))
|
||||
.orderby(JournalEntry.name, order=frappe.qb.desc)
|
||||
.limit(page_len)
|
||||
.offset(start)
|
||||
)
|
||||
|
||||
party = filters.get("party")
|
||||
if party:
|
||||
query = query.where(JournalEntryAccount.party == party)
|
||||
else:
|
||||
query = query.where(JournalEntryAccount.party.isnull() | (JournalEntryAccount.party == ""))
|
||||
|
||||
# each names one value. A list would be read as a filter operator below and widen the search
|
||||
# past what the caller named.
|
||||
for value in (account, party):
|
||||
if value and not isinstance(value, str):
|
||||
frappe.throw(_("Invalid filter"), frappe.PermissionError)
|
||||
|
||||
# get_list applies the permission query conditions; the child-table filter resolves the check to `read`
|
||||
je_filters = [
|
||||
["docstatus", "=", 1],
|
||||
[searchfield, "like", f"%{txt}%"],
|
||||
["Journal Entry Account", "account", "=", account],
|
||||
["Journal Entry Account", "reference_type", "is", "not set"],
|
||||
]
|
||||
je_filters.append(
|
||||
["Journal Entry Account", "party", "=", party]
|
||||
if party
|
||||
else ["Journal Entry Account", "party", "is", "not set"]
|
||||
)
|
||||
|
||||
return frappe.get_list(
|
||||
"Journal Entry",
|
||||
filters=je_filters,
|
||||
fields=["name", "posting_date", "remark"],
|
||||
order_by="name desc",
|
||||
limit_start=start,
|
||||
limit_page_length=page_len,
|
||||
as_list=True,
|
||||
# one row per entry, not per matching account row. group_by rather than distinct: frappe
|
||||
# drops ORDER BY from a distinct query on postgres, which would lose the ordering above.
|
||||
group_by="name",
|
||||
)
|
||||
return query.run()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
@@ -253,10 +253,6 @@ def make_reverse_journal_entry(source_name: str, target_doc: str | dict | Docume
|
||||
|
||||
def post_process(source, target) -> None:
|
||||
target.reversal_of = source.name
|
||||
target.naming_series = source.naming_series
|
||||
if source.voucher_type == "Bank Entry":
|
||||
target.cheque_no = source.cheque_no
|
||||
target.cheque_date = source.cheque_date
|
||||
|
||||
doclist = get_mapped_doc(
|
||||
"Journal Entry",
|
||||
|
||||
@@ -461,59 +461,6 @@ class TestJournalEntry(ERPNextTestSuite):
|
||||
|
||||
self.check_gl_entries()
|
||||
|
||||
def make_jv_with_fractional_totals(self):
|
||||
"""0.10 + 0.20 sums to 0.30000000000000004, the residue this guards against."""
|
||||
jv = frappe.new_doc("Journal Entry")
|
||||
jv.posting_date = nowdate()
|
||||
jv.company = "_Test Company"
|
||||
jv.voucher_type = "Journal Entry"
|
||||
jv.remark = "test"
|
||||
for amount in (0.10, 0.20):
|
||||
jv.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": "_Test Cash - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"debit_in_account_currency": amount,
|
||||
},
|
||||
)
|
||||
jv.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": "_Test Bank - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"credit_in_account_currency": 0.30,
|
||||
},
|
||||
)
|
||||
jv.insert()
|
||||
return jv
|
||||
|
||||
def test_totals_are_rounded_to_precision(self):
|
||||
jv = self.make_jv_with_fractional_totals()
|
||||
jv.submit()
|
||||
|
||||
stored = frappe.db.get_value(
|
||||
"Journal Entry", jv.name, ["total_debit", "total_credit", "difference"], as_dict=True
|
||||
)
|
||||
self.assertEqual(jv.total_debit, flt(jv.total_debit, jv.precision("total_debit")))
|
||||
self.assertEqual(jv.total_credit, flt(jv.total_credit, jv.precision("total_credit")))
|
||||
self.assertEqual(jv.total_debit, stored.total_debit)
|
||||
self.assertEqual(jv.total_credit, stored.total_credit)
|
||||
self.assertEqual(jv.difference, stored.difference)
|
||||
|
||||
def test_update_after_submit_with_fractional_totals(self):
|
||||
"""An unrounded total is stored rounded, so updating a submitted entry used to throw."""
|
||||
jv = self.make_jv_with_fractional_totals()
|
||||
jv.submit()
|
||||
|
||||
jv.pay_to_recd_from = "_Test Supplier"
|
||||
jv.save()
|
||||
|
||||
self.assertEqual(jv.docstatus, 1)
|
||||
self.assertEqual(
|
||||
jv.pay_to_recd_from, frappe.db.get_value("Journal Entry", jv.name, "pay_to_recd_from")
|
||||
)
|
||||
|
||||
def test_jv_account_and_party_balance_with_cost_centre(self):
|
||||
from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center
|
||||
from erpnext.accounts.utils import get_balance_on
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"creation": "2020-04-09 01:32:51.332301",
|
||||
"doctype": "DocType",
|
||||
"document_type": "Document",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/journal-entry-template",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
@@ -86,7 +85,6 @@
|
||||
"label": "Multi Currency"
|
||||
}
|
||||
],
|
||||
"icon": "notebook-text",
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:09:58.814734",
|
||||
"modified_by": "Administrator",
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
"label": "General and Payment Ledger mismatch"
|
||||
}
|
||||
],
|
||||
"icon": "heart-pulse",
|
||||
"in_create": 1,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
|
||||
@@ -57,7 +57,6 @@
|
||||
}
|
||||
],
|
||||
"hide_toolbar": 1,
|
||||
"icon": "activity",
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
|
||||
@@ -92,7 +92,6 @@
|
||||
}
|
||||
],
|
||||
"hide_toolbar": 1,
|
||||
"icon": "merge",
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:10:02.198009",
|
||||
"modified_by": "Administrator",
|
||||
|
||||
@@ -97,7 +97,6 @@
|
||||
"label": "Discretionary Reason"
|
||||
}
|
||||
],
|
||||
"icon": "star",
|
||||
"in_create": 1,
|
||||
"links": [],
|
||||
"modified": "2024-07-01 08:51:13.927009",
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"autoname": "field:loyalty_program_name",
|
||||
"creation": "2018-01-23 06:23:05.731431",
|
||||
"doctype": "DocType",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/loyalty-program",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
@@ -154,7 +153,6 @@
|
||||
"options": "Project"
|
||||
}
|
||||
],
|
||||
"icon": "gift",
|
||||
"links": [],
|
||||
"modified": "2026-08-21 23:11:44.144864",
|
||||
"modified_by": "Administrator",
|
||||
|
||||
@@ -129,11 +129,6 @@ def get_loyalty_program_details(
|
||||
silent: bool = False,
|
||||
include_expired_entry: bool = False,
|
||||
):
|
||||
# Same guard as get_loyalty_program_details_with_points above: the customer is what the caller
|
||||
# is entitled to, not the programme. A check on Loyalty Program itself would be read-only to
|
||||
# System Manager and would deny every role that actually fills in the two calling forms.
|
||||
frappe.has_permission("Customer", doc=customer, throw=True)
|
||||
|
||||
lp_details = frappe._dict()
|
||||
|
||||
if not loyalty_program:
|
||||
@@ -155,13 +150,6 @@ def get_loyalty_program_details(
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_redeemption_factor(loyalty_program: str | None = None, customer: str | None = None):
|
||||
# both call sites send only `loyalty_program`, so the calling form is the boundary; the customer branch stays guarded
|
||||
if not (frappe.has_permission("Sales Invoice") or frappe.has_permission("POS Invoice")):
|
||||
frappe.throw(_("Not permitted"), frappe.PermissionError)
|
||||
|
||||
if customer:
|
||||
frappe.has_permission("Customer", doc=customer, throw=True)
|
||||
|
||||
customer_loyalty_program = None
|
||||
if not loyalty_program:
|
||||
customer_loyalty_program = frappe.db.get_value("Customer", customer, "loyalty_program")
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
"creation": "2012-12-04 17:49:20",
|
||||
"doctype": "DocType",
|
||||
"document_type": "Setup",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/mode-of-payment",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"mode_of_payment",
|
||||
@@ -45,7 +44,7 @@
|
||||
"label": "Enabled"
|
||||
}
|
||||
],
|
||||
"icon": "wallet",
|
||||
"icon": "fa fa-credit-card",
|
||||
"idx": 1,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"options": "Monthly Distribution Percentage"
|
||||
}
|
||||
],
|
||||
"icon": "chart-bar",
|
||||
"icon": "fa fa-bar-chart",
|
||||
"idx": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-21 23:11:44.908490",
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"allow_copy": 1,
|
||||
"creation": "2017-08-29 02:22:54.947711",
|
||||
"doctype": "DocType",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/opening-invoice-creation-tool",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
@@ -89,7 +88,6 @@
|
||||
}
|
||||
],
|
||||
"hide_toolbar": 1,
|
||||
"icon": "file-plus",
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-30 23:18:48.691227",
|
||||
|
||||
@@ -50,7 +50,6 @@
|
||||
"options": "secondary_role"
|
||||
}
|
||||
],
|
||||
"icon": "link",
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:10:08.607170",
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
// For license information, please see license.txt
|
||||
frappe.provide("erpnext.accounts.dimensions");
|
||||
|
||||
cur_frm.cscript.tax_table = "Advance Taxes and Charges";
|
||||
|
||||
erpnext.accounts.taxes.setup_tax_validations("Payment Entry");
|
||||
erpnext.accounts.taxes.setup_tax_filters("Advance Taxes and Charges");
|
||||
|
||||
@@ -44,29 +46,23 @@ frappe.ui.form.on("Payment Entry", {
|
||||
},
|
||||
|
||||
setup: function (frm) {
|
||||
frm.cscript.tax_table = "Advance Taxes and Charges";
|
||||
|
||||
frm.set_query("paid_from", function (doc) {
|
||||
frm.set_query("paid_from", function () {
|
||||
frm.events.validate_company(frm);
|
||||
|
||||
var account_types = ["Pay", "Internal Transfer"].includes(frm.doc.payment_type)
|
||||
? ["Bank", "Cash"]
|
||||
: [frappe.boot.party_account_types[frm.doc.party_type]];
|
||||
let filters = {
|
||||
account_type: ["in", account_types],
|
||||
is_group: 0,
|
||||
company: doc.company,
|
||||
};
|
||||
|
||||
if (frm.doc.party_type == "Shareholder") {
|
||||
account_types.push("Equity");
|
||||
}
|
||||
if (doc.payment_type == "Internal Transfer" && doc.paid_to) {
|
||||
filters.name = ["!=", doc.paid_to];
|
||||
}
|
||||
|
||||
return {
|
||||
filters,
|
||||
filters: {
|
||||
account_type: ["in", account_types],
|
||||
is_group: 0,
|
||||
company: frm.doc.company,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -110,25 +106,21 @@ frappe.ui.form.on("Payment Entry", {
|
||||
}
|
||||
});
|
||||
|
||||
frm.set_query("paid_to", function (doc) {
|
||||
frm.set_query("paid_to", function () {
|
||||
frm.events.validate_company(frm);
|
||||
|
||||
var account_types = ["Receive", "Internal Transfer"].includes(frm.doc.payment_type)
|
||||
? ["Bank", "Cash"]
|
||||
: [frappe.boot.party_account_types[frm.doc.party_type]];
|
||||
let filters = {
|
||||
account_type: ["in", account_types],
|
||||
is_group: 0,
|
||||
company: doc.company,
|
||||
};
|
||||
if (frm.doc.party_type == "Shareholder") {
|
||||
account_types.push("Equity");
|
||||
}
|
||||
if (doc.payment_type == "Internal Transfer" && doc.paid_from) {
|
||||
filters.name = ["!=", doc.paid_from];
|
||||
}
|
||||
return {
|
||||
filters,
|
||||
filters: {
|
||||
account_type: ["in", account_types],
|
||||
is_group: 0,
|
||||
company: frm.doc.company,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"autoname": "naming_series:",
|
||||
"creation": "2016-06-01 14:38:51.012597",
|
||||
"doctype": "DocType",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/payment-entry",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"type_of_payment",
|
||||
@@ -785,7 +784,6 @@
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"icon": "banknote",
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [
|
||||
|
||||
@@ -176,7 +176,6 @@ class PaymentEntry(AccountsController):
|
||||
self.set_liability_account()
|
||||
self.set_missing_ref_details(force=True)
|
||||
self.validate_payment_type()
|
||||
self.validate_internal_transfer_accounts()
|
||||
self.validate_party_details()
|
||||
self.set_exchange_rate()
|
||||
self.validate_mandatory()
|
||||
@@ -209,15 +208,9 @@ class PaymentEntry(AccountsController):
|
||||
self.update_payment_schedule()
|
||||
self.make_gl_entries()
|
||||
self.update_outstanding_amounts()
|
||||
self.update_linked_dunnings()
|
||||
self.set_status()
|
||||
self.trigger_invoice_update_for_subscriptions()
|
||||
|
||||
def update_linked_dunnings(self):
|
||||
from erpnext.accounts.doctype.dunning.dunning import update_dunnings_linked_to_payment
|
||||
|
||||
update_dunnings_linked_to_payment(self)
|
||||
|
||||
def validate_for_repost(self):
|
||||
validate_docs_for_voucher_types(["Payment Entry"])
|
||||
validate_docs_for_deferred_accounting([self.name], [])
|
||||
@@ -322,7 +315,6 @@ class PaymentEntry(AccountsController):
|
||||
self.update_payment_schedule(cancel=1)
|
||||
self.make_gl_entries(cancel=1)
|
||||
self.update_outstanding_amounts()
|
||||
self.update_linked_dunnings()
|
||||
self.delink_advance_entry_references()
|
||||
self.set_status()
|
||||
self.trigger_invoice_update_for_subscriptions()
|
||||
@@ -635,10 +627,6 @@ class PaymentEntry(AccountsController):
|
||||
if self.payment_type not in ("Receive", "Pay", "Internal Transfer"):
|
||||
frappe.throw(_("Payment Type must be one of Receive, Pay, or Internal Transfer"))
|
||||
|
||||
def validate_internal_transfer_accounts(self):
|
||||
if self.payment_type == "Internal Transfer" and self.paid_from and self.paid_from == self.paid_to:
|
||||
frappe.throw(_("Paid From and Paid To accounts must be different for an Internal Transfer."))
|
||||
|
||||
def validate_party_details(self):
|
||||
if self.party and not frappe.db.exists(self.party_type, self.party):
|
||||
frappe.throw(_("{0} {1} does not exist").format(_(self.party_type), self.party))
|
||||
@@ -2737,7 +2725,7 @@ def get_payment_entry(
|
||||
pe.append("references", reference)
|
||||
else:
|
||||
if dt == "Dunning":
|
||||
for overdue_payment, outstanding in doc.get_unpaid_overdue_payments():
|
||||
for overdue_payment in doc.overdue_payments:
|
||||
pe.append(
|
||||
"references",
|
||||
{
|
||||
@@ -2745,23 +2733,21 @@ def get_payment_entry(
|
||||
"reference_name": overdue_payment.sales_invoice,
|
||||
"payment_term": overdue_payment.payment_term,
|
||||
"due_date": overdue_payment.due_date,
|
||||
"total_amount": outstanding,
|
||||
"outstanding_amount": outstanding,
|
||||
"allocated_amount": outstanding,
|
||||
"total_amount": overdue_payment.outstanding,
|
||||
"outstanding_amount": overdue_payment.outstanding,
|
||||
"allocated_amount": overdue_payment.outstanding,
|
||||
},
|
||||
)
|
||||
|
||||
if (unpaid_dunning_amount := doc.get_unpaid_base_dunning_amount()) > 0:
|
||||
pe.append(
|
||||
"deductions",
|
||||
{
|
||||
"account": doc.income_account,
|
||||
"cost_center": doc.cost_center,
|
||||
"amount": -1 * unpaid_dunning_amount,
|
||||
"description": _("Interest and/or dunning fee"),
|
||||
"dunning": doc.name,
|
||||
},
|
||||
)
|
||||
pe.append(
|
||||
"deductions",
|
||||
{
|
||||
"account": doc.income_account,
|
||||
"cost_center": doc.cost_center,
|
||||
"amount": -1 * doc.dunning_amount,
|
||||
"description": _("Interest and/or dunning fee"),
|
||||
},
|
||||
)
|
||||
else:
|
||||
pe.append(
|
||||
"references",
|
||||
@@ -3054,10 +3040,8 @@ def set_grand_total_and_outstanding_amount(party_amount, dt, party_account_curre
|
||||
grand_total = doc.rounded_total or doc.grand_total
|
||||
outstanding_amount = doc.outstanding_amount
|
||||
elif dt == "Dunning":
|
||||
# only what is left to collect, the totals on the dunning are the ones it was raised with
|
||||
grand_total = sum(outstanding for _row, outstanding in doc.get_unpaid_overdue_payments())
|
||||
grand_total += doc.get_unpaid_dunning_amount()
|
||||
outstanding_amount = grand_total
|
||||
grand_total = doc.grand_total
|
||||
outstanding_amount = doc.grand_total
|
||||
else:
|
||||
if party_account_currency == doc.company_currency:
|
||||
grand_total = flt(doc.get("base_rounded_total") or doc.get("base_grand_total"))
|
||||
|
||||
@@ -782,23 +782,6 @@ class TestPaymentEntry(ERPNextTestSuite):
|
||||
|
||||
self.validate_gl_entries(pe.name, expected_gle)
|
||||
|
||||
def test_internal_transfer_rejects_same_account(self):
|
||||
pe = frappe.new_doc("Payment Entry")
|
||||
pe.payment_type = "Internal Transfer"
|
||||
pe.company = "_Test Company"
|
||||
pe.paid_from = "_Test Bank - _TC"
|
||||
pe.paid_to = "_Test Bank - _TC"
|
||||
pe.paid_amount = 100
|
||||
pe.received_amount = 100
|
||||
pe.reference_no = "same-account-transfer"
|
||||
pe.reference_date = nowdate()
|
||||
|
||||
self.assertRaisesRegex(
|
||||
frappe.ValidationError,
|
||||
"Paid From and Paid To accounts must be different",
|
||||
pe.insert,
|
||||
)
|
||||
|
||||
def test_bank_charges_deduction(self):
|
||||
bank_charges_account = create_account(
|
||||
parent_account="Indirect Expenses - _TC",
|
||||
|
||||
@@ -10,8 +10,7 @@
|
||||
"amount",
|
||||
"column_break_2",
|
||||
"is_exchange_gain_loss",
|
||||
"description",
|
||||
"dunning"
|
||||
"description"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -56,21 +55,12 @@
|
||||
"fieldtype": "Check",
|
||||
"label": "System Generated",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "dunning",
|
||||
"fieldtype": "Link",
|
||||
"label": "Dunning",
|
||||
"no_copy": 1,
|
||||
"options": "Dunning",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-17 11:20:35.482913",
|
||||
"modified": "2026-03-11 14:26:11.312950",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Payment Entry Deduction",
|
||||
|
||||
@@ -18,7 +18,6 @@ class PaymentEntryDeduction(Document):
|
||||
amount: DF.Currency
|
||||
cost_center: DF.Link
|
||||
description: DF.SmallText | None
|
||||
dunning: DF.Link | None
|
||||
is_exchange_gain_loss: DF.Check
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
|
||||
@@ -84,7 +84,6 @@
|
||||
"reqd": 1
|
||||
}
|
||||
],
|
||||
"icon": "credit-card",
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-07-14 16:49:55.210352",
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"icon": "book-open",
|
||||
"is_submittable": 1,
|
||||
"field_order": [
|
||||
"posting_date",
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"autoname": "naming_series:",
|
||||
"creation": "2018-07-20 16:43:08.505978",
|
||||
"doctype": "DocType",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/payment-order",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
@@ -108,7 +107,6 @@
|
||||
"label": "Account"
|
||||
}
|
||||
],
|
||||
"icon": "send",
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:10:10.186727",
|
||||
|
||||
@@ -57,27 +57,9 @@ class PaymentOrder(Document):
|
||||
frappe.db.set_value(self.payment_order_type, d.get(ref_doc_field), ref_field, status)
|
||||
|
||||
|
||||
def _readable_payment_order(filters: dict) -> str | None:
|
||||
"""Authorise the parent before reading its rows.
|
||||
|
||||
A child table carries no permissions of its own, so a read of it has to be authorised on the
|
||||
Payment Order the rows belong to.
|
||||
"""
|
||||
parent = filters.get("parent")
|
||||
if not parent or not frappe.db.exists("Payment Order", parent):
|
||||
return None
|
||||
|
||||
ptype = "select" if frappe.only_has_select_perm("Payment Order") else "read"
|
||||
frappe.has_permission("Payment Order", ptype, doc=parent, throw=True)
|
||||
return parent
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_mop_query(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
|
||||
if not _readable_payment_order(filters):
|
||||
return []
|
||||
|
||||
return frappe.get_all(
|
||||
"Payment Order Reference",
|
||||
filters={"parent": filters.get("parent"), "mode_of_payment": ["like", f"%{txt}%"]},
|
||||
@@ -92,9 +74,6 @@ def get_mop_query(doctype: str, txt: str, searchfield: str, start: int, page_len
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_supplier_query(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
|
||||
if not _readable_payment_order(filters):
|
||||
return []
|
||||
|
||||
return frappe.get_all(
|
||||
"Payment Order Reference",
|
||||
filters={
|
||||
|
||||
@@ -109,13 +109,12 @@ erpnext.accounts.PaymentReconciliationController = class PaymentReconciliationCo
|
||||
}
|
||||
|
||||
this.frm.trigger("set_query_for_dimension_filters");
|
||||
this.update_totals();
|
||||
this.bind_totals_on_row_select();
|
||||
|
||||
// check for any running reconciliation jobs
|
||||
if (this.frm.doc.receivable_payable_account) {
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.payment_reconciliation.payment_reconciliation.is_auto_process_enabled",
|
||||
this.frm.call({
|
||||
doc: this.frm.doc,
|
||||
method: "is_auto_process_enabled",
|
||||
callback: (r) => {
|
||||
if (r.message) {
|
||||
this.frm
|
||||
@@ -224,31 +223,6 @@ erpnext.accounts.PaymentReconciliationController = class PaymentReconciliationCo
|
||||
this.frm.clear_table("payments");
|
||||
this.frm.clear_table("allocation");
|
||||
this.frm.refresh_fields();
|
||||
this.update_totals();
|
||||
}
|
||||
|
||||
update_totals() {
|
||||
const sum_outstanding = (rows) => rows.reduce((total, row) => total + flt(row.outstanding_amount), 0);
|
||||
const sum_amount = (rows) => rows.reduce((total, row) => total + flt(row.amount), 0);
|
||||
|
||||
const selected_invoices = this.frm.fields_dict.invoices.grid.get_selected_children();
|
||||
const selected_payments = this.frm.fields_dict.payments.grid.get_selected_children();
|
||||
|
||||
const total_invoice_amount = sum_outstanding(selected_invoices);
|
||||
const total_payment_amount = sum_amount(selected_payments);
|
||||
this.frm.set_value({
|
||||
total_invoice_amount,
|
||||
total_payment_amount,
|
||||
difference_amount: total_invoice_amount - total_payment_amount,
|
||||
});
|
||||
}
|
||||
|
||||
bind_totals_on_row_select() {
|
||||
["invoices", "payments"].forEach((fieldname) => {
|
||||
this.frm.fields_dict[fieldname].grid.wrapper
|
||||
.off("click.pr_totals")
|
||||
.on("click.pr_totals", ".grid-row-check", () => this.update_totals());
|
||||
});
|
||||
}
|
||||
|
||||
get_unreconciled_entries() {
|
||||
@@ -257,7 +231,6 @@ erpnext.accounts.PaymentReconciliationController = class PaymentReconciliationCo
|
||||
doc: this.frm.doc,
|
||||
method: "get_unreconciled_entries",
|
||||
callback: () => {
|
||||
this.update_totals();
|
||||
if (!(this.frm.doc.payments.length || this.frm.doc.invoices.length)) {
|
||||
frappe.throw({
|
||||
message: __("No Unreconciled Invoices and Payments found for this party and account"),
|
||||
@@ -458,4 +431,4 @@ frappe.ui.form.on("Payment Reconciliation Allocation", {
|
||||
},
|
||||
});
|
||||
|
||||
frappe.ui.form.set_controller("Payment Reconciliation", erpnext.accounts.PaymentReconciliationController);
|
||||
extend_cscript(cur_frm.cscript, new erpnext.accounts.PaymentReconciliationController({ frm: cur_frm }));
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"allow_copy": 1,
|
||||
"creation": "2014-07-09 12:04:51.681583",
|
||||
"doctype": "DocType",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/payment-reconciliation",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"company",
|
||||
@@ -11,7 +10,6 @@
|
||||
"column_break_4",
|
||||
"party",
|
||||
"receivable_payable_account",
|
||||
"currency",
|
||||
"default_advance_account",
|
||||
"col_break1",
|
||||
"from_invoice_date",
|
||||
@@ -37,12 +35,6 @@
|
||||
"column_break_15",
|
||||
"payment_name",
|
||||
"payments",
|
||||
"totals_section",
|
||||
"total_invoice_amount",
|
||||
"column_break_totals_1",
|
||||
"total_payment_amount",
|
||||
"column_break_totals_2",
|
||||
"difference_amount",
|
||||
"sec_break2",
|
||||
"allocation"
|
||||
],
|
||||
@@ -78,15 +70,6 @@
|
||||
"options": "Account",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fetch_from": "receivable_payable_account.account_currency",
|
||||
"fieldname": "currency",
|
||||
"fieldtype": "Link",
|
||||
"hidden": 1,
|
||||
"label": "Currency",
|
||||
"options": "Currency",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"description": "This filter will be applied to Journal Entry.",
|
||||
"fieldname": "bank_cash_account",
|
||||
@@ -116,41 +99,6 @@
|
||||
"label": "Payments",
|
||||
"options": "Payment Reconciliation Payment"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:(doc.payments).length || (doc.invoices).length",
|
||||
"fieldname": "totals_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Totals"
|
||||
},
|
||||
{
|
||||
"fieldname": "total_invoice_amount",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Total Invoice Amount",
|
||||
"options": "currency",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_totals_1",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "total_payment_amount",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Total Payment Amount",
|
||||
"options": "currency",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_totals_2",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "difference_amount",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Difference Amount",
|
||||
"options": "currency",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "allocation",
|
||||
"fieldname": "sec_break2",
|
||||
@@ -287,7 +235,7 @@
|
||||
}
|
||||
],
|
||||
"hide_toolbar": 1,
|
||||
"icon": "arrow-left-right",
|
||||
"icon": "icon-resize-horizontal",
|
||||
"is_virtual": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
|
||||
@@ -460,6 +460,10 @@ class PaymentReconciliation(Document):
|
||||
|
||||
return difference_amount
|
||||
|
||||
@frappe.whitelist()
|
||||
def is_auto_process_enabled(self):
|
||||
return frappe.get_single_value("Accounts Settings", "auto_reconcile_payments")
|
||||
|
||||
@frappe.whitelist()
|
||||
def calculate_difference_on_allocation_change(
|
||||
self, payment_entry: list, invoice: list, allocated_amount: float
|
||||
@@ -482,13 +486,6 @@ class PaymentReconciliation(Document):
|
||||
"Accounts Settings", "exchange_gain_loss_posting_date", cache=True
|
||||
)
|
||||
invoice_exchange_map = self.get_invoice_exchange_map(args.get("invoices"), args.get("payments"))
|
||||
account_currency = frappe.get_cached_value(
|
||||
"Account", self.receivable_payable_account, "account_currency"
|
||||
)
|
||||
allocated_amount_precision = get_field_precision(
|
||||
frappe.get_meta("Payment Reconciliation Allocation").get_field("allocated_amount"),
|
||||
currency=account_currency,
|
||||
)
|
||||
|
||||
entries = []
|
||||
for pay in args.get("payments"):
|
||||
@@ -496,17 +493,11 @@ class PaymentReconciliation(Document):
|
||||
for inv in args.get("invoices"):
|
||||
if pay.get("amount") >= inv.get("outstanding_amount"):
|
||||
res = self.get_allocated_entry(pay, inv, inv["outstanding_amount"])
|
||||
pay["amount"] = flt(
|
||||
flt(pay.get("amount")) - flt(inv.get("outstanding_amount")),
|
||||
allocated_amount_precision,
|
||||
)
|
||||
pay["amount"] = flt(pay.get("amount")) - flt(inv.get("outstanding_amount"))
|
||||
inv["outstanding_amount"] = 0
|
||||
else:
|
||||
res = self.get_allocated_entry(pay, inv, pay["amount"])
|
||||
inv["outstanding_amount"] = flt(
|
||||
flt(inv.get("outstanding_amount")) - flt(pay.get("amount")),
|
||||
allocated_amount_precision,
|
||||
)
|
||||
inv["outstanding_amount"] = flt(inv.get("outstanding_amount")) - flt(pay.get("amount"))
|
||||
pay["amount"] = 0
|
||||
|
||||
inv["exchange_rate"] = invoice_exchange_map.get(inv.get("invoice_number"))
|
||||
@@ -976,8 +967,3 @@ def get_queries_for_dimension_filters(company: str | None = None):
|
||||
dimensions_with_filters.append({"fieldname": d.fieldname, "filters": filters})
|
||||
|
||||
return dimensions_with_filters
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def is_auto_process_enabled():
|
||||
return frappe.get_single_value("Accounts Settings", "auto_reconcile_payments")
|
||||
|
||||
@@ -1659,77 +1659,6 @@ class TestPaymentReconciliation(ERPNextTestSuite):
|
||||
# Should not raise frappe.exceptions.ValidationError: Payment Entry has been modified after you pulled it. Please pull it again.
|
||||
pr.reconcile()
|
||||
|
||||
@ERPNextTestSuite.change_settings("System Settings", {"currency_precision": 2})
|
||||
def test_allocate_entries_rounds_running_balance_to_currency_precision(self):
|
||||
pr = frappe.new_doc("Payment Reconciliation")
|
||||
pr.company = self.company
|
||||
pr.party_type = "Customer"
|
||||
pr.party = self.customer
|
||||
pr.receivable_payable_account = self.debit_to
|
||||
pr.set("invoices", [{"invoice_number": "INV-1"}])
|
||||
pr.set("payments", [{"reference_name": "PAY-1"}])
|
||||
|
||||
invoices = [
|
||||
{
|
||||
"invoice_type": "Sales Invoice",
|
||||
"invoice_number": "INV-1",
|
||||
"outstanding_amount": 17592.415,
|
||||
"currency": "INR",
|
||||
},
|
||||
]
|
||||
payments = [
|
||||
{
|
||||
"reference_type": "Payment Entry",
|
||||
"reference_name": "PAY-1",
|
||||
"amount": 18230,
|
||||
"currency": "INR",
|
||||
}
|
||||
]
|
||||
|
||||
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
|
||||
|
||||
self.assertEqual(payments[0]["amount"], flt(637.585, 2))
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"System Settings", {"currency_precision": "", "use_number_format_from_currency": 1}
|
||||
)
|
||||
def test_allocate_entries_rounds_running_balance_to_account_currency_precision(self):
|
||||
account_currency = frappe.get_cached_value("Account", self.debit_to, "account_currency")
|
||||
original_number_format = frappe.db.get_value("Currency", account_currency, "number_format")
|
||||
frappe.db.set_value("Currency", account_currency, "number_format", "#,###.###")
|
||||
self.addCleanup(
|
||||
frappe.db.set_value, "Currency", account_currency, "number_format", original_number_format
|
||||
)
|
||||
|
||||
pr = frappe.new_doc("Payment Reconciliation")
|
||||
pr.company = self.company
|
||||
pr.party_type = "Customer"
|
||||
pr.party = self.customer
|
||||
pr.receivable_payable_account = self.debit_to
|
||||
pr.set("invoices", [{"invoice_number": "INV-1"}])
|
||||
pr.set("payments", [{"reference_name": "PAY-1"}])
|
||||
|
||||
invoices = [
|
||||
{
|
||||
"invoice_type": "Sales Invoice",
|
||||
"invoice_number": "INV-1",
|
||||
"outstanding_amount": 17592.415,
|
||||
"currency": account_currency,
|
||||
},
|
||||
]
|
||||
payments = [
|
||||
{
|
||||
"reference_type": "Payment Entry",
|
||||
"reference_name": "PAY-1",
|
||||
"amount": 18230,
|
||||
"currency": account_currency,
|
||||
}
|
||||
]
|
||||
|
||||
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
|
||||
|
||||
self.assertEqual(payments[0]["amount"], flt(637.585, 3))
|
||||
|
||||
def test_reverse_payment_against_payment_for_supplier(self):
|
||||
"""
|
||||
Reconcile a payment against a reverse payment, for a supplier.
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
cur_frm.add_fetch("payment_gateway_account", "payment_account", "payment_account");
|
||||
cur_frm.add_fetch("payment_gateway_account", "payment_gateway", "payment_gateway");
|
||||
cur_frm.add_fetch("payment_gateway_account", "message", "message");
|
||||
|
||||
frappe.ui.form.on("Payment Request", {
|
||||
setup: function (frm) {
|
||||
frm.add_fetch("payment_gateway_account", "message", "message");
|
||||
|
||||
frm.set_query("party_type", function () {
|
||||
return {
|
||||
query: "erpnext.setup.doctype.party_type.party_type.get_party_type",
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"autoname": "naming_series:",
|
||||
"creation": "2015-12-15 22:23:24.745065",
|
||||
"doctype": "DocType",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/payment-request",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"payment_request_type",
|
||||
@@ -475,7 +474,6 @@
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"icon": "hand-coins",
|
||||
"in_create": 1,
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user