Compare commits

..

17 Commits

Author SHA1 Message Date
khushi8112
c970614956 refactor: fix incorrect conditon 2025-11-03 11:10:31 +05:30
khushi8112
9b60864e46 fix: remove duplicate method 2025-10-31 23:25:17 +05:30
khushi8112
6a891048a8 chore: validation for none type object 2025-10-31 23:25:17 +05:30
khushi8112
6d55b1801e fix: use correct date value 2025-10-31 23:25:17 +05:30
khushi8112
8dc62e71e4 chore: fix typo 2025-10-31 23:25:17 +05:30
khushi8112
c5e5ac9eef fix: use correct field name 2025-10-31 23:25:17 +05:30
khushi8112
93d281837a fix: validate pending reposting till acc frozen date 2025-10-31 23:25:16 +05:30
Khushi Rawat
3a81e2c3c8 chore: resolved conflicts 2025-10-31 23:24:39 +05:30
Khushi Rawat
b7ceb468f7 chore: remove debug flag accidentally left in code 2025-10-31 23:24:39 +05:30
Khushi Rawat
5130dc408a fix: update validation and test cases 2025-10-31 23:24:37 +05:30
Khushi Rawat
b27b35e1ae chore: migration patch for account freezing fields 2025-10-31 23:23:25 +05:30
Khushi Rawat
eead484fd3 refactor: remove accounts freezing settings from accounts settings 2025-10-31 23:22:48 +05:30
Khushi Rawat
ab5e821220 refactor: get frozen accounts settings from Company in tests 2025-10-31 23:22:48 +05:30
Khushi Rawat
7bce3ac7fa refactor: get frozen accounts settings from Company in patches 2025-10-31 23:22:48 +05:30
Khushi Rawat
e088427e92 refactor: get frozen accounts settings from Company in Deferred Revenue 2025-10-31 23:22:48 +05:30
Khushi Rawat
1affdaa94d refactor: updated logic in depreciation and gl to validate acc frozen date company wise 2025-10-31 23:22:48 +05:30
Khushi Rawat
936b81b404 feat: move frozen account settings to Company for company-specific configuration 2025-10-31 23:22:45 +05:30
785 changed files with 354354 additions and 238654 deletions

View File

@@ -45,9 +45,3 @@ d827ed21adc7b36047e247cbb0dc6388d048a7f9
# `frappe.flags.in_test` => `frappe.in_test`
7a482a69985c952de0e8193c9d4e086aee65ee6d
# these commits actually changed something valuable
# but they have a lot of whitespace changes that make blame noisy
# PR: https://github.com/frappe/erpnext/pull/49816
3ffd50c772735877b330d010c1058f623da8721d
0e8f8677b8eb31e7834f72d1c6314d3c3f392ca6

View File

@@ -14,7 +14,7 @@ jobs:
timeout-minutes: 60
steps:
- name: Checkout Actions
uses: actions/checkout@v6
uses: actions/checkout@v2
with:
repository: "frappe/backport"
path: ./actions

View File

@@ -13,12 +13,12 @@ jobs:
steps:
- name: 'Setup Environment'
uses: actions/setup-python@v6
uses: actions/setup-python@v2
with:
python-version: '3.10'
- name: 'Clone repo'
uses: actions/checkout@v6
uses: actions/checkout@v2
- name: Validate Docs
env:

View File

@@ -21,14 +21,14 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
ref: ${{ matrix.branch }}
- name: Setup Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: "3.14"
python-version: "3.12"
- name: Run script to update POT file
run: |

View File

@@ -19,7 +19,7 @@ jobs:
strategy:
fail-fast: false
matrix:
version: ["14", "15", "16"]
version: ["14", "15"]
steps:
- uses: octokit/request-action@v2.x

View File

@@ -12,12 +12,12 @@ jobs:
name: linters
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v3
- name: Set up Python 3.14
uses: actions/setup-python@v6
- name: Set up Python 3.10
uses: actions/setup-python@v4
with:
python-version: '3.14'
python-version: '3.10'
cache: pip
- name: Install and Run Pre-commit
@@ -27,12 +27,12 @@ jobs:
name: semgrep
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v3
- name: Set up Python 3.14
uses: actions/setup-python@v6
- name: Set up Python 3.10
uses: actions/setup-python@v4
with:
python-version: '3.14'
python-version: '3.10'
cache: pip
- name: Download Semgrep rules

View File

@@ -29,7 +29,7 @@ jobs:
services:
mysql:
image: mariadb:11.8
image: mariadb:10.6
env:
MARIADB_ROOT_PASSWORD: 'root'
ports:
@@ -38,7 +38,7 @@ jobs:
steps:
- name: Clone
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Check for valid Python & Merge Conflicts
run: |
@@ -49,17 +49,14 @@ jobs:
fi
- name: Setup Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: |
3.11
3.13
3.14
python-version: '3.11'
- name: Setup Node
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: 24
node-version: 18
check-latest: true
- name: Add to Hosts
@@ -113,8 +110,8 @@ jobs:
jq 'del(.install_apps)' ~/frappe-bench/sites/test_site/site_config.json > tmp.json
mv tmp.json ~/frappe-bench/sites/test_site/site_config.json
wget https://frappe.io/files/erpnext-v14.sql.gz
bench --site test_site --force restore ~/frappe-bench/erpnext-v14.sql.gz
wget https://erpnext.com/files/v13-erpnext.sql.gz
bench --site test_site --force restore ~/frappe-bench/v13-erpnext.sql.gz
git -C "apps/frappe" remote set-url upstream https://github.com/frappe/frappe.git
git -C "apps/erpnext" remote set-url upstream https://github.com/frappe/erpnext.git
@@ -135,14 +132,15 @@ jobs:
# Resetup env and install apps
pgrep honcho | xargs kill
rm -rf ~/frappe-bench/env
bench -v setup env --python python$2
bench -v setup env
bench pip install -e ./apps/erpnext
bench start &>> ~/frappe-bench/bench_start.log &
bench --site test_site migrate
}
update_to_version 15 3.13
update_to_version 14
update_to_version 15
echo "Updating to latest version"
git -C "apps/frappe" fetch --depth 1 upstream "${GITHUB_BASE_REF:-${GITHUB_REF##*/}}"

View File

@@ -2,7 +2,7 @@ name: Generate Semantic Release
on:
push:
branches:
- version-16
- version-13
permissions:
contents: read
@@ -13,12 +13,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Entire Repository
uses: actions/checkout@v6
uses: actions/checkout@v2
with:
fetch-depth: 0
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v2
with:
node-version: 20
- name: Setup dependencies

View File

@@ -17,7 +17,7 @@ jobs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- name: Clone
uses: actions/checkout@v6
uses: actions/checkout@v4
- id: set-matrix
run: |
# Use grep and find to get the list of test files
@@ -72,17 +72,17 @@ jobs:
steps:
- name: Clone
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: '3.14'
python-version: '3.12'
- name: Setup Node
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: 24
node-version: 18
check-latest: true
- name: Add to Hosts

View File

@@ -15,11 +15,11 @@ jobs:
name: Check Commit Titles
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v3
with:
fetch-depth: 200
- uses: actions/setup-node@v6
- uses: actions/setup-node@v3
with:
node-version: 18
check-latest: true

View File

@@ -7,7 +7,6 @@ on:
paths-ignore:
- '**.js'
- '**.css'
- '**.svg'
- '**.md'
- '**.html'
- 'crowdin.yml'
@@ -63,12 +62,12 @@ jobs:
steps:
- name: Clone
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: '3.14'
python-version: '3.12'
- name: Check for valid Python & Merge Conflicts
run: |
@@ -79,9 +78,9 @@ jobs:
fi
- name: Setup Node
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: 24
node-version: 18
check-latest: true
- name: Add to Hosts
@@ -129,9 +128,10 @@ jobs:
FRAPPE_BRANCH: ${{ github.event.client_payload.sha || github.event.inputs.branch }}
- name: Run Tests
run: 'cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --app erpnext --total-builds ${{ strategy.job-total }} --build-number ${{ matrix.container }} --with-coverage'
run: 'cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --app erpnext --total-builds ${{ strategy.job-total }} --build-number ${{ matrix.container }}'
env:
TYPE: server
CAPTURE_COVERAGE: ${{ github.event_name != 'pull_request' }}
- name: Show bench output
@@ -140,6 +140,7 @@ jobs:
- name: Upload coverage data
uses: actions/upload-artifact@v4
if: github.event_name != 'pull_request'
with:
name: coverage-${{ matrix.container }}
path: /home/runner/frappe-bench/sites/coverage.xml
@@ -148,9 +149,10 @@ jobs:
name: Coverage Wrap Up
needs: test
runs-on: ubuntu-latest
if: ${{ github.event_name != 'pull_request' }}
steps:
- name: Clone
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Download artifacts
uses: actions/download-artifact@v4

View File

@@ -47,12 +47,12 @@ jobs:
steps:
- name: Clone
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: '3.14'
python-version: '3.12'
- name: Check for valid Python & Merge Conflicts
run: |
@@ -63,9 +63,9 @@ jobs:
fi
- name: Setup Node
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: 24
node-version: 18
check-latest: true
- name: Add to Hosts

View File

@@ -50,15 +50,6 @@ pull_request_rules:
- version-15-hotfix
assignees:
- "{{ author }}"
- name: backport to version-16-beta
conditions:
- label="backport version-16-beta"
actions:
backport:
branches:
- version-16-beta
assignees:
- "{{ author }}"
- name: Automatic merge on CI success and review
conditions:
- status-success=linters

View File

@@ -6,7 +6,7 @@ import frappe
from frappe.model.document import Document
from frappe.utils.user import is_website_user
__version__ = "16.0.0"
__version__ = "16.0.0-dev"
def get_default_company(user=None):

View File

@@ -9,20 +9,18 @@
"idx": 0,
"is_public": 1,
"is_standard": 1,
"last_synced_on": "2026-01-02 13:01:24.037552",
"modified": "2026-01-02 13:04:57.850305",
"last_synced_on": "2020-07-22 12:19:59.879476",
"modified": "2020-07-22 12:21:48.780513",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Bank Balance",
"number_of_groups": 0,
"owner": "Administrator",
"roles": [],
"show_values_over_chart": 1,
"source": "Account Balance Timeline",
"time_interval": "Monthly",
"timeseries": 1,
"time_interval": "Quarterly",
"timeseries": 0,
"timespan": "Last Year",
"type": "Line",
"use_report_chart": 0,
"y_axis": []
}
}

View File

@@ -1,7 +1,7 @@
{
"chart_name": "Profit and Loss",
"chart_type": "Report",
"creation": "2025-04-01 20:38:16.986176",
"creation": "2020-07-17 11:25:34.448572",
"docstatus": 0,
"doctype": "Dashboard Chart",
"dynamic_filters_json": "{\"company\":\"frappe.defaults.get_user_default(\\\"Company\\\")\",\"from_fiscal_year\":\"erpnext.utils.get_fiscal_year()\",\"to_fiscal_year\":\"erpnext.utils.get_fiscal_year()\"}",
@@ -9,7 +9,7 @@
"idx": 0,
"is_public": 1,
"is_standard": 1,
"modified": "2025-12-19 12:37:31.673782",
"modified": "2023-07-19 13:08:56.470390",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Profit and Loss",
@@ -17,9 +17,8 @@
"owner": "Administrator",
"report_name": "Profit and Loss Statement",
"roles": [],
"show_values_over_chart": 1,
"timeseries": 0,
"type": "Line",
"type": "Bar",
"use_report_chart": 1,
"y_axis": []
}
}

View File

@@ -450,12 +450,14 @@ def process_deferred_accounting(posting_date=None):
for company in companies:
for record_type in ("Income", "Expense"):
doc = frappe.get_doc(
doctype="Process Deferred Accounting",
company=company.name,
posting_date=posting_date,
start_date=start_date,
end_date=end_date,
type=record_type,
dict(
doctype="Process Deferred Accounting",
company=company.name,
posting_date=posting_date,
start_date=start_date,
end_date=end_date,
type=record_type,
)
)
doc.insert()

View File

@@ -21,7 +21,6 @@
"account_currency",
"column_break1",
"parent_account",
"account_category",
"account_type",
"tax_rate",
"freeze_account",
@@ -190,20 +189,13 @@
"fieldname": "disabled",
"fieldtype": "Check",
"label": "Disable"
},
{
"description": "Used with Financial Report Template",
"fieldname": "account_category",
"fieldtype": "Link",
"label": "Account Category",
"options": "Account Category"
}
],
"icon": "fa fa-money",
"idx": 1,
"is_tree": 1,
"links": [],
"modified": "2025-08-02 06:26:44.657146",
"modified": "2025-01-22 10:40:35.766017",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Account",
@@ -258,7 +250,6 @@
"write": 1
}
],
"row_format": "Dynamic",
"search_fields": "account_number",
"show_name_in_global_search": 1,
"show_preview_popup": 1,
@@ -266,4 +257,4 @@
"sort_order": "ASC",
"states": [],
"track_changes": 1
}
}

View File

@@ -31,7 +31,6 @@ class Account(NestedSet):
if TYPE_CHECKING:
from frappe.types import DF
account_category: DF.Link | None
account_currency: DF.Link | None
account_name: DF.Data
account_number: DF.Data | None

View File

@@ -70,7 +70,6 @@ frappe.treeview_settings["Account"] = {
args: {
accounts: accounts,
company: cur_tree.args.company,
include_default_fb_balances: true,
},
});
@@ -161,14 +160,6 @@ frappe.treeview_settings["Account"] = {
.options,
description: __("Optional. This setting will be used to filter in various transactions."),
},
{
fieldtype: "Link",
fieldname: "account_category",
label: __("Account Category"),
options: frappe.get_meta("Account").fields.filter((d) => d.fieldname == "account_category")[0]
.options,
description: __("Optional. Used with Financial Report Template"),
},
{
fieldtype: "Float",
fieldname: "tax_rate",

View File

@@ -23,7 +23,15 @@ def create_charts(
if root_account:
root_type = child.get("root_type")
if account_name not in get_chart_metadata_fields():
if account_name not in [
"account_name",
"account_number",
"account_type",
"root_type",
"is_group",
"tax_rate",
"account_currency",
]:
account_number = cstr(child.get("account_number")).strip()
account_name, account_name_in_db = add_suffix_if_duplicate(
account_name, account_number, accounts
@@ -47,7 +55,6 @@ def create_charts(
"report_type": report_type,
"account_number": account_number,
"account_type": child.get("account_type"),
"account_category": child.get("account_category"),
"account_currency": child.get("account_currency")
if custom_chart
else frappe.get_cached_value("Company", company, "default_currency"),
@@ -90,7 +97,20 @@ def add_suffix_if_duplicate(account_name, account_number, accounts):
def identify_is_group(child):
if child.get("is_group"):
is_group = child.get("is_group")
elif len(set(child.keys()) - set(get_chart_metadata_fields())):
elif len(
set(child.keys())
- set(
[
"account_name",
"account_type",
"root_type",
"is_group",
"tax_rate",
"account_number",
"account_currency",
]
)
):
is_group = 1
else:
is_group = 0
@@ -233,7 +253,13 @@ def validate_bank_account(coa, bank_account):
def _get_account_names(account_master):
for account_name, child in account_master.items():
if account_name not in get_chart_metadata_fields():
if account_name not in [
"account_number",
"account_type",
"root_type",
"is_group",
"tax_rate",
]:
accounts.append(account_name)
_get_account_names(child)
@@ -258,7 +284,15 @@ def build_tree_from_json(chart_template, chart_data=None, from_coa_importer=Fals
"""recursively called to form a parent-child based list of dict from chart template"""
for account_name, child in children.items():
account = {}
if account_name in get_chart_metadata_fields():
if account_name in [
"account_name",
"account_number",
"account_type",
"root_type",
"is_group",
"tax_rate",
"account_currency",
]:
continue
if from_coa_importer:
@@ -276,16 +310,3 @@ def build_tree_from_json(chart_template, chart_data=None, from_coa_importer=Fals
_import_accounts(chart, None)
return accounts
def get_chart_metadata_fields():
return [
"account_name",
"account_number",
"account_type",
"account_category",
"root_type",
"is_group",
"tax_rate",
"account_currency",
]

View File

@@ -9,192 +9,103 @@ def get():
return {
_("Application of Funds (Assets)"): {
_("Current Assets"): {
_("Accounts Receivable"): {
_("Debtors"): {"account_type": "Receivable", "account_category": "Trade Receivables"}
},
_("Bank Accounts"): {
"account_type": "Bank",
"is_group": 1,
"account_category": "Cash and Cash Equivalents",
},
_("Cash In Hand"): {
_("Cash"): {"account_type": "Cash", "account_category": "Cash and Cash Equivalents"},
"account_type": "Cash",
"account_category": "Cash and Cash Equivalents",
},
_("Accounts Receivable"): {_("Debtors"): {"account_type": "Receivable"}},
_("Bank Accounts"): {"account_type": "Bank", "is_group": 1},
_("Cash In Hand"): {_("Cash"): {"account_type": "Cash"}, "account_type": "Cash"},
_("Loans and Advances (Assets)"): {
_("Employee Advances"): {
"account_type": "Payable",
"account_category": "Other Receivables",
},
_("Employee Advances"): {"account_type": "Payable"},
},
_("Securities and Deposits"): {
_("Earnest Money"): {"account_category": "Other Current Assets"}
},
_("Prepaid Expenses"): {"account_category": "Other Current Assets"},
_("Short-term Investments"): {"account_category": "Short-term Investments"},
_("Securities and Deposits"): {_("Earnest Money"): {}},
_("Stock Assets"): {
_("Stock In Hand"): {"account_type": "Stock", "account_category": "Stock Assets"},
_("Stock In Hand"): {"account_type": "Stock"},
"account_type": "Stock",
"account_category": "Stock Assets",
},
_("Tax Assets"): {"is_group": 1, "account_category": "Other Current Assets"},
_("Tax Assets"): {"is_group": 1},
},
_("Fixed Assets"): {
_("Capital Equipment"): {
"account_type": "Fixed Asset",
"account_category": "Tangible Assets",
},
_("Electronic Equipment"): {
"account_type": "Fixed Asset",
"account_category": "Tangible Assets",
},
_("Furniture and Fixtures"): {
"account_type": "Fixed Asset",
"account_category": "Tangible Assets",
},
_("Office Equipment"): {"account_type": "Fixed Asset", "account_category": "Tangible Assets"},
_("Plants and Machineries"): {
"account_type": "Fixed Asset",
"account_category": "Tangible Assets",
},
_("Buildings"): {"account_type": "Fixed Asset", "account_category": "Tangible Assets"},
_("Software"): {"account_type": "Fixed Asset", "account_category": "Intangible Assets"},
_("Accumulated Depreciation"): {
"account_type": "Accumulated Depreciation",
"account_category": "Tangible Assets",
},
_("Capital Equipment"): {"account_type": "Fixed Asset"},
_("Electronic Equipment"): {"account_type": "Fixed Asset"},
_("Furniture and Fixtures"): {"account_type": "Fixed Asset"},
_("Office Equipment"): {"account_type": "Fixed Asset"},
_("Plants and Machineries"): {"account_type": "Fixed Asset"},
_("Buildings"): {"account_type": "Fixed Asset"},
_("Software"): {"account_type": "Fixed Asset"},
_("Accumulated Depreciation"): {"account_type": "Accumulated Depreciation"},
_("CWIP Account"): {
"account_type": "Capital Work in Progress",
"account_category": "Tangible Assets",
},
},
_("Investments"): {"is_group": 1, "account_category": "Long-term Investments"},
_("Temporary Accounts"): {
_("Temporary Opening"): {
"account_type": "Temporary",
"account_category": "Other Non-current Assets",
}
},
_("Investments"): {"is_group": 1},
_("Temporary Accounts"): {_("Temporary Opening"): {"account_type": "Temporary"}},
"root_type": "Asset",
},
_("Expenses"): {
_("Direct Expenses"): {
_("Stock Expenses"): {
_("Cost of Goods Sold"): {
"account_type": "Cost of Goods Sold",
"account_category": "Cost of Goods Sold",
},
_("Cost of Goods Sold"): {"account_type": "Cost of Goods Sold"},
_("Expenses Included In Asset Valuation"): {
"account_type": "Expenses Included In Asset Valuation",
"account_category": "Other Direct Costs",
},
_("Expenses Included In Valuation"): {
"account_type": "Expenses Included In Valuation",
"account_category": "Other Direct Costs",
},
_("Stock Adjustment"): {
"account_type": "Stock Adjustment",
"account_category": "Other Direct Costs",
"account_type": "Expenses Included In Asset Valuation"
},
_("Expenses Included In Valuation"): {"account_type": "Expenses Included In Valuation"},
_("Stock Adjustment"): {"account_type": "Stock Adjustment"},
},
},
_("Indirect Expenses"): {
_("Administrative Expenses"): {"account_category": "Operating Expenses"},
_("Commission on Sales"): {"account_category": "Operating Expenses"},
_("Depreciation"): {"account_type": "Depreciation", "account_category": "Operating Expenses"},
_("Entertainment Expenses"): {"account_category": "Operating Expenses"},
_("Freight and Forwarding Charges"): {
"account_type": "Chargeable",
"account_category": "Operating Expenses",
},
_("Legal Expenses"): {"account_category": "Operating Expenses"},
_("Marketing Expenses"): {
"account_type": "Chargeable",
"account_category": "Operating Expenses",
},
_("Miscellaneous Expenses"): {
"account_type": "Chargeable",
"account_category": "Operating Expenses",
},
_("Office Maintenance Expenses"): {"account_category": "Operating Expenses"},
_("Office Rent"): {"account_category": "Operating Expenses"},
_("Postal Expenses"): {"account_category": "Operating Expenses"},
_("Print and Stationery"): {"account_category": "Operating Expenses"},
_("Round Off"): {"account_type": "Round Off", "account_category": "Operating Expenses"},
_("Salary"): {"account_category": "Operating Expenses"},
_("Sales Expenses"): {"account_category": "Operating Expenses"},
_("Telephone Expenses"): {"account_category": "Operating Expenses"},
_("Travel Expenses"): {"account_category": "Operating Expenses"},
_("Utility Expenses"): {"account_category": "Operating Expenses"},
_("Write Off"): {"account_category": "Operating Expenses"},
_("Exchange Gain/Loss"): {"account_category": "Operating Expenses"},
_("Interest Expense"): {"account_category": "Finance Costs"},
_("Bank Charges"): {"account_category": "Finance Costs"},
_("Gain/Loss on Asset Disposal"): {"account_category": "Other Operating Income"},
_("Impairment"): {"account_category": "Operating Expenses"},
_("Tax Expense"): {"account_category": "Tax Expense"},
_("Administrative Expenses"): {},
_("Commission on Sales"): {},
_("Depreciation"): {"account_type": "Depreciation"},
_("Entertainment Expenses"): {},
_("Freight and Forwarding Charges"): {"account_type": "Chargeable"},
_("Legal Expenses"): {},
_("Marketing Expenses"): {"account_type": "Chargeable"},
_("Miscellaneous Expenses"): {"account_type": "Chargeable"},
_("Office Maintenance Expenses"): {},
_("Office Rent"): {},
_("Postal Expenses"): {},
_("Print and Stationery"): {},
_("Round Off"): {"account_type": "Round Off"},
_("Salary"): {},
_("Sales Expenses"): {},
_("Telephone Expenses"): {},
_("Travel Expenses"): {},
_("Utility Expenses"): {},
_("Write Off"): {},
_("Exchange Gain/Loss"): {},
_("Gain/Loss on Asset Disposal"): {},
_("Impairment"): {},
},
"root_type": "Expense",
},
_("Income"): {
_("Direct Income"): {
_("Sales"): {"account_category": "Revenue from Operations"},
_("Service"): {"account_category": "Revenue from Operations"},
},
_("Indirect Income"): {
_("Interest Income"): {"account_category": "Investment Income"},
_("Interest on Fixed Deposits"): {"account_category": "Investment Income"},
"is_group": 1,
},
_("Direct Income"): {_("Sales"): {}, _("Service"): {}},
_("Indirect Income"): {"is_group": 1},
"root_type": "Income",
},
_("Source of Funds (Liabilities)"): {
_("Current Liabilities"): {
_("Accounts Payable"): {
_("Creditors"): {"account_type": "Payable", "account_category": "Trade Payables"},
_("Payroll Payable"): {"account_category": "Other Payables"},
_("Creditors"): {"account_type": "Payable"},
_("Payroll Payable"): {},
},
_("Accrued Expenses"): {"account_category": "Other Current Liabilities"},
_("Customer Advances"): {"account_category": "Other Current Liabilities"},
_("Stock Liabilities"): {
_("Stock Received But Not Billed"): {
"account_type": "Stock Received But Not Billed",
"account_category": "Trade Payables",
},
_("Asset Received But Not Billed"): {
"account_type": "Asset Received But Not Billed",
"account_category": "Trade Payables",
},
_("Stock Received But Not Billed"): {"account_type": "Stock Received But Not Billed"},
_("Asset Received But Not Billed"): {"account_type": "Asset Received But Not Billed"},
},
_("Duties and Taxes"): {
"account_type": "Tax",
"is_group": 1,
"account_category": "Current Tax Liabilities",
},
_("Short-term Provisions"): {"account_category": "Short-term Provisions"},
_("Duties and Taxes"): {"account_type": "Tax", "is_group": 1},
_("Loans (Liabilities)"): {
_("Secured Loans"): {"account_category": "Long-term Borrowings"},
_("Unsecured Loans"): {"account_category": "Long-term Borrowings"},
_("Bank Overdraft Account"): {"account_category": "Short-term Borrowings"},
_("Secured Loans"): {},
_("Unsecured Loans"): {},
_("Bank Overdraft Account"): {},
},
},
_("Non-Current Liabilities"): {
_("Long-term Provisions"): {"account_category": "Long-term Provisions"},
_("Employee Benefits Obligation"): {"account_category": "Other Non-current Liabilities"},
"is_group": 1,
},
"root_type": "Liability",
},
_("Equity"): {
_("Capital Stock"): {"account_type": "Equity", "account_category": "Share Capital"},
_("Dividends Paid"): {"account_type": "Equity", "account_category": "Reserves and Surplus"},
_("Opening Balance Equity"): {
"account_type": "Equity",
"account_category": "Reserves and Surplus",
},
_("Retained Earnings"): {"account_type": "Equity", "account_category": "Reserves and Surplus"},
_("Revaluation Surplus"): {"account_type": "Equity", "account_category": "Reserves and Surplus"},
_("Capital Stock"): {"account_type": "Equity"},
_("Dividends Paid"): {"account_type": "Equity"},
_("Opening Balance Equity"): {"account_type": "Equity"},
_("Retained Earnings"): {"account_type": "Equity"},
_("Revaluation Surplus"): {"account_type": "Equity"},
"root_type": "Equity",
},
}

View File

@@ -10,128 +10,49 @@ def get():
_("Application of Funds (Assets)"): {
_("Current Assets"): {
_("Accounts Receivable"): {
_("Debtors"): {
"account_type": "Receivable",
"account_number": "1310",
"account_category": "Trade Receivables",
},
_("Debtors"): {"account_type": "Receivable", "account_number": "1310"},
"account_number": "1300",
},
_("Bank Accounts"): {
"account_type": "Bank",
"is_group": 1,
"account_number": "1200",
"account_category": "Cash and Cash Equivalents",
},
_("Bank Accounts"): {"account_type": "Bank", "is_group": 1, "account_number": "1200"},
_("Cash In Hand"): {
_("Cash"): {
"account_type": "Cash",
"account_number": "1110",
"account_category": "Cash and Cash Equivalents",
},
_("Cash"): {"account_type": "Cash", "account_number": "1110"},
"account_type": "Cash",
"account_number": "1100",
"account_category": "Cash and Cash Equivalents",
},
_("Loans and Advances (Assets)"): {
_("Employee Advances"): {
"account_number": "1610",
"account_type": "Payable",
"account_category": "Other Receivables",
},
_("Employee Advances"): {"account_number": "1610", "account_type": "Payable"},
"account_number": "1600",
},
_("Securities and Deposits"): {
_("Earnest Money"): {
"account_number": "1651",
"account_category": "Other Current Assets",
},
_("Earnest Money"): {"account_number": "1651"},
"account_number": "1650",
},
_("Prepaid Expenses"): {
"account_number": "1660",
"account_category": "Other Current Assets",
},
_("Short-term Investments"): {
"account_number": "1670",
"account_category": "Short-term Investments",
},
_("Stock Assets"): {
_("Stock In Hand"): {
"account_type": "Stock",
"account_number": "1410",
"account_category": "Stock Assets",
},
_("Stock In Hand"): {"account_type": "Stock", "account_number": "1410"},
"account_type": "Stock",
"account_number": "1400",
"account_category": "Stock Assets",
},
_("Tax Assets"): {
"is_group": 1,
"account_number": "1500",
"account_category": "Other Current Assets",
},
_("Tax Assets"): {"is_group": 1, "account_number": "1500"},
"account_number": "1100-1600",
},
_("Fixed Assets"): {
_("Capital Equipment"): {
"account_type": "Fixed Asset",
"account_number": "1710",
"account_category": "Tangible Assets",
},
_("Electronic Equipment"): {
"account_type": "Fixed Asset",
"account_number": "1720",
"account_category": "Tangible Assets",
},
_("Furniture and Fixtures"): {
"account_type": "Fixed Asset",
"account_number": "1730",
"account_category": "Tangible Assets",
},
_("Office Equipment"): {
"account_type": "Fixed Asset",
"account_number": "1740",
"account_category": "Tangible Assets",
},
_("Plants and Machineries"): {
"account_type": "Fixed Asset",
"account_number": "1750",
"account_category": "Tangible Assets",
},
_("Buildings"): {
"account_type": "Fixed Asset",
"account_number": "1760",
"account_category": "Tangible Assets",
},
_("Software"): {
"account_type": "Fixed Asset",
"account_number": "1770",
"account_category": "Intangible Assets",
},
_("Capital Equipment"): {"account_type": "Fixed Asset", "account_number": "1710"},
_("Electronic Equipment"): {"account_type": "Fixed Asset", "account_number": "1720"},
_("Furniture and Fixtures"): {"account_type": "Fixed Asset", "account_number": "1730"},
_("Office Equipment"): {"account_type": "Fixed Asset", "account_number": "1740"},
_("Plants and Machineries"): {"account_type": "Fixed Asset", "account_number": "1750"},
_("Buildings"): {"account_type": "Fixed Asset", "account_number": "1760"},
_("Software"): {"account_type": "Fixed Asset", "account_number": "1770"},
_("Accumulated Depreciation"): {
"account_type": "Accumulated Depreciation",
"account_number": "1780",
"account_category": "Tangible Assets",
},
_("CWIP Account"): {
"account_type": "Capital Work in Progress",
"account_number": "1790",
"account_category": "Tangible Assets",
},
_("CWIP Account"): {"account_type": "Capital Work in Progress", "account_number": "1790"},
"account_number": "1700",
},
_("Investments"): {
"is_group": 1,
"account_number": "1800",
"account_category": "Long-term Investments",
},
_("Investments"): {"is_group": 1, "account_number": "1800"},
_("Temporary Accounts"): {
_("Temporary Opening"): {
"account_type": "Temporary",
"account_number": "1910",
"account_category": "Other Non-current Assets",
},
_("Temporary Opening"): {"account_type": "Temporary", "account_number": "1910"},
"account_number": "1900",
},
"root_type": "Asset",
@@ -140,94 +61,42 @@ def get():
_("Expenses"): {
_("Direct Expenses"): {
_("Stock Expenses"): {
_("Cost of Goods Sold"): {
"account_type": "Cost of Goods Sold",
"account_number": "5111",
"account_category": "Cost of Goods Sold",
},
_("Cost of Goods Sold"): {"account_type": "Cost of Goods Sold", "account_number": "5111"},
_("Expenses Included In Asset Valuation"): {
"account_type": "Expenses Included In Asset Valuation",
"account_number": "5112",
"account_category": "Other Direct Costs",
},
_("Expenses Included In Valuation"): {
"account_type": "Expenses Included In Valuation",
"account_number": "5118",
"account_category": "Other Direct Costs",
},
_("Stock Adjustment"): {
"account_type": "Stock Adjustment",
"account_number": "5119",
"account_category": "Other Direct Costs",
},
_("Stock Adjustment"): {"account_type": "Stock Adjustment", "account_number": "5119"},
"account_number": "5110",
},
"account_number": "5100",
},
_("Indirect Expenses"): {
_("Administrative Expenses"): {
"account_number": "5201",
"account_category": "Operating Expenses",
},
_("Commission on Sales"): {
"account_number": "5202",
"account_category": "Operating Expenses",
},
_("Depreciation"): {
"account_type": "Depreciation",
"account_number": "5203",
"account_category": "Operating Expenses",
},
_("Entertainment Expenses"): {
"account_number": "5204",
"account_category": "Operating Expenses",
},
_("Freight and Forwarding Charges"): {
"account_type": "Chargeable",
"account_number": "5205",
"account_category": "Operating Expenses",
},
_("Legal Expenses"): {"account_number": "5206", "account_category": "Operating Expenses"},
_("Marketing Expenses"): {
"account_type": "Chargeable",
"account_number": "5207",
"account_category": "Operating Expenses",
},
_("Office Maintenance Expenses"): {
"account_number": "5208",
"account_category": "Operating Expenses",
},
_("Office Rent"): {"account_number": "5209", "account_category": "Operating Expenses"},
_("Postal Expenses"): {"account_number": "5210", "account_category": "Operating Expenses"},
_("Print and Stationery"): {
"account_number": "5211",
"account_category": "Operating Expenses",
},
_("Round Off"): {
"account_type": "Round Off",
"account_number": "5212",
"account_category": "Operating Expenses",
},
_("Salary"): {"account_number": "5213", "account_category": "Operating Expenses"},
_("Sales Expenses"): {"account_number": "5214", "account_category": "Operating Expenses"},
_("Telephone Expenses"): {"account_number": "5215", "account_category": "Operating Expenses"},
_("Travel Expenses"): {"account_number": "5216", "account_category": "Operating Expenses"},
_("Utility Expenses"): {"account_number": "5217", "account_category": "Operating Expenses"},
_("Write Off"): {"account_number": "5218", "account_category": "Operating Expenses"},
_("Exchange Gain/Loss"): {"account_number": "5219", "account_category": "Operating Expenses"},
_("Interest Expense"): {"account_number": "5220", "account_category": "Finance Costs"},
_("Bank Charges"): {"account_number": "5221", "account_category": "Finance Costs"},
_("Gain/Loss on Asset Disposal"): {
"account_number": "5222",
"account_category": "Other Operating Income",
},
_("Miscellaneous Expenses"): {
"account_type": "Chargeable",
"account_number": "5223",
"account_category": "Operating Expenses",
},
_("Impairment"): {"account_number": "5224", "account_category": "Operating Expenses"},
_("Tax Expense"): {"account_number": "5225", "account_category": "Tax Expense"},
_("Administrative Expenses"): {"account_number": "5201"},
_("Commission on Sales"): {"account_number": "5202"},
_("Depreciation"): {"account_type": "Depreciation", "account_number": "5203"},
_("Entertainment Expenses"): {"account_number": "5204"},
_("Freight and Forwarding Charges"): {"account_type": "Chargeable", "account_number": "5205"},
_("Legal Expenses"): {"account_number": "5206"},
_("Marketing Expenses"): {"account_type": "Chargeable", "account_number": "5207"},
_("Office Maintenance Expenses"): {"account_number": "5208"},
_("Office Rent"): {"account_number": "5209"},
_("Postal Expenses"): {"account_number": "5210"},
_("Print and Stationery"): {"account_number": "5211"},
_("Round Off"): {"account_type": "Round Off", "account_number": "5212"},
_("Salary"): {"account_number": "5213"},
_("Sales Expenses"): {"account_number": "5214"},
_("Telephone Expenses"): {"account_number": "5215"},
_("Travel Expenses"): {"account_number": "5216"},
_("Utility Expenses"): {"account_number": "5217"},
_("Write Off"): {"account_number": "5218"},
_("Exchange Gain/Loss"): {"account_number": "5219"},
_("Gain/Loss on Asset Disposal"): {"account_number": "5220"},
_("Miscellaneous Expenses"): {"account_type": "Chargeable", "account_number": "5221"},
"account_number": "5200",
},
"root_type": "Expense",
@@ -235,126 +104,54 @@ def get():
},
_("Income"): {
_("Direct Income"): {
_("Sales"): {"account_number": "4110", "account_category": "Revenue from Operations"},
_("Service"): {"account_number": "4120", "account_category": "Revenue from Operations"},
_("Sales"): {"account_number": "4110"},
_("Service"): {"account_number": "4120"},
"account_number": "4100",
},
_("Indirect Income"): {
_("Interest Income"): {"account_number": "4210", "account_category": "Investment Income"},
_("Interest on Fixed Deposits"): {
"account_number": "4220",
"account_category": "Investment Income",
},
"is_group": 1,
"account_number": "4200",
},
_("Indirect Income"): {"is_group": 1, "account_number": "4200"},
"root_type": "Income",
"account_number": "4000",
},
_("Source of Funds (Liabilities)"): {
_("Current Liabilities"): {
_("Accounts Payable"): {
_("Creditors"): {
"account_type": "Payable",
"account_number": "2110",
"account_category": "Trade Payables",
},
_("Payroll Payable"): {"account_number": "2120", "account_category": "Other Payables"},
_("Creditors"): {"account_type": "Payable", "account_number": "2110"},
_("Payroll Payable"): {"account_number": "2120"},
"account_number": "2100",
},
_("Accrued Expenses"): {
"account_number": "2150",
"account_category": "Other Current Liabilities",
},
_("Customer Advances"): {
"account_number": "2160",
"account_category": "Other Current Liabilities",
},
_("Stock Liabilities"): {
_("Stock Received But Not Billed"): {
"account_type": "Stock Received But Not Billed",
"account_number": "2210",
"account_category": "Trade Payables",
},
_("Asset Received But Not Billed"): {
"account_type": "Asset Received But Not Billed",
"account_number": "2211",
"account_category": "Trade Payables",
},
"account_number": "2200",
},
_("Duties and Taxes"): {
_("TDS Payable"): {
"account_number": "2310",
"account_category": "Current Tax Liabilities",
},
_("TDS Payable"): {"account_number": "2310"},
"account_type": "Tax",
"is_group": 1,
"account_number": "2300",
"account_category": "Current Tax Liabilities",
},
_("Short-term Provisions"): {
"account_number": "2350",
"account_category": "Short-term Provisions",
},
_("Loans (Liabilities)"): {
_("Secured Loans"): {
"account_number": "2410",
"account_category": "Long-term Borrowings",
},
_("Unsecured Loans"): {
"account_number": "2420",
"account_category": "Long-term Borrowings",
},
_("Bank Overdraft Account"): {
"account_number": "2430",
"account_category": "Short-term Borrowings",
},
_("Secured Loans"): {"account_number": "2410"},
_("Unsecured Loans"): {"account_number": "2420"},
_("Bank Overdraft Account"): {"account_number": "2430"},
"account_number": "2400",
},
"account_number": "2100-2400",
},
_("Non-Current Liabilities"): {
_("Long-term Provisions"): {
"account_number": "2510",
"account_category": "Long-term Provisions",
},
_("Employee Benefits Obligation"): {
"account_number": "2520",
"account_category": "Other Non-current Liabilities",
},
"is_group": 1,
"account_number": "2500",
},
"root_type": "Liability",
"account_number": "2000",
},
_("Equity"): {
_("Capital Stock"): {
"account_type": "Equity",
"account_number": "3100",
"account_category": "Share Capital",
},
_("Dividends Paid"): {
"account_type": "Equity",
"account_number": "3200",
"account_category": "Reserves and Surplus",
},
_("Opening Balance Equity"): {
"account_type": "Equity",
"account_number": "3300",
"account_category": "Reserves and Surplus",
},
_("Retained Earnings"): {
"account_type": "Equity",
"account_number": "3400",
"account_category": "Reserves and Surplus",
},
_("Revaluation Surplus"): {
"account_type": "Equity",
"account_number": "3500",
"account_category": "Reserves and Surplus",
},
_("Capital Stock"): {"account_type": "Equity", "account_number": "3100"},
_("Dividends Paid"): {"account_type": "Equity", "account_number": "3200"},
_("Opening Balance Equity"): {"account_type": "Equity", "account_number": "3300"},
_("Retained Earnings"): {"account_type": "Equity", "account_number": "3400"},
"root_type": "Equity",
"account_number": "3000",
},

View File

@@ -415,13 +415,15 @@ def create_account(**kwargs):
return account.name
else:
account = frappe.get_doc(
doctype="Account",
is_group=kwargs.get("is_group", 0),
account_name=kwargs.get("account_name"),
account_type=kwargs.get("account_type"),
parent_account=kwargs.get("parent_account"),
company=kwargs.get("company"),
account_currency=kwargs.get("account_currency"),
dict(
doctype="Account",
is_group=kwargs.get("is_group", 0),
account_name=kwargs.get("account_name"),
account_type=kwargs.get("account_type"),
parent_account=kwargs.get("parent_account"),
company=kwargs.get("company"),
account_currency=kwargs.get("account_currency"),
)
)
account.save()

View File

@@ -1,8 +0,0 @@
// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
// frappe.ui.form.on("Account Category", {
// refresh(frm) {
// },
// });

View File

@@ -1,71 +0,0 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "field:account_category_name",
"creation": "2025-08-02 06:22:31.835063",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"account_category_name",
"description"
],
"fields": [
{
"fieldname": "account_category_name",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Account Category Name",
"reqd": 1,
"unique": 1
},
{
"fieldname": "description",
"fieldtype": "Small Text",
"label": "Description"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-10-15 03:19:47.171349",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Account Category",
"naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Accounts User",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Accounts Manager",
"share": 1,
"write": 1
},
{
"read": 1,
"role": "Auditor"
}
],
"row_format": "Dynamic",
"search_fields": "account_category_name, description",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View File

@@ -1,94 +0,0 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import json
import os
import frappe
from frappe import _
from frappe.model.document import Document, bulk_insert
DOCTYPE = "Account Category"
class AccountCategory(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frappe.types import DF
account_category_name: DF.Data
description: DF.SmallText | None
# end: auto-generated types
def after_rename(self, old_name, new_name, merge):
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
FormulaFieldUpdater,
)
# get all template rows with this account category being used
row = frappe.qb.DocType("Financial Report Row")
rows = frappe._dict(
frappe.qb.from_(row)
.select(row.name, row.calculation_formula)
.where(row.calculation_formula.like(f"%{old_name}%"))
.run()
)
if not rows:
return
# Update formulas with new name
updater = FormulaFieldUpdater(
field_name="account_category",
value_mapping={old_name: new_name},
exclude_operators=["like", "not like"],
)
updated_formulas = updater.update_in_rows(rows)
if updated_formulas:
frappe.msgprint(
_("Updated {0} Financial Report Row(s) with new category name").format(len(updated_formulas))
)
def import_account_categories(template_path: str):
categories_file = os.path.join(template_path, "account_categories.json")
if not os.path.exists(categories_file):
return
with open(categories_file) as f:
categories = json.load(f, object_hook=frappe._dict)
create_account_categories(categories)
def create_account_categories(categories: list[dict]):
if not categories:
return
existing_categories = set(frappe.get_all(DOCTYPE, pluck="name"))
new_categories = []
for category_data in categories:
category_name = category_data.get("account_category_name")
if not category_name or category_name in existing_categories:
continue
doc = frappe.get_doc(
{
**category_data,
"doctype": DOCTYPE,
"name": category_name,
}
)
new_categories.append(doc)
existing_categories.add(category_name)
if new_categories:
bulk_insert(DOCTYPE, new_categories)

View File

@@ -1,20 +0,0 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
from frappe.tests import IntegrationTestCase
# On IntegrationTestCase, the doctype test records and all
# link-field test record dependencies are recursively loaded
# Use these module variables to add/remove to/from that list
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
class IntegrationTestAccountCategory(IntegrationTestCase):
"""
Integration tests for AccountCategory.
Use this class for testing interactions between multiple components.
"""
pass

View File

@@ -309,8 +309,8 @@ def get_dimensions(with_cost_center_and_project=False):
if with_cost_center_and_project:
dimension_filters.extend(
[
frappe._dict({"fieldname": "cost_center", "document_type": "Cost Center"}),
frappe._dict({"fieldname": "project", "document_type": "Project"}),
{"fieldname": "cost_center", "document_type": "Cost Center"},
{"fieldname": "project", "document_type": "Project"},
]
)

View File

@@ -12,7 +12,6 @@
"column_break_4",
"company",
"disabled",
"exempted_role",
"section_break_7",
"closed_documents"
],
@@ -68,18 +67,10 @@
"label": "Closed Documents",
"options": "Closed Document",
"reqd": 1
},
{
"description": "Role allowed to bypass period restrictions.",
"fieldname": "exempted_role",
"fieldtype": "Link",
"label": "Exempted Role",
"link_filters": "[[\"Role\",\"disabled\",\"=\",0]]",
"options": "Role"
}
],
"links": [],
"modified": "2025-12-01 16:53:44.631299",
"modified": "2025-10-06 15:00:15.568067",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounting Period",

View File

@@ -30,7 +30,6 @@ class AccountingPeriod(Document):
company: DF.Link
disabled: DF.Check
end_date: DF.Date
exempted_role: DF.Link | None
period_name: DF.Data
start_date: DF.Date
# end: auto-generated types
@@ -114,7 +113,7 @@ def validate_accounting_period_on_doc_save(doc, method=None):
accounting_period = (
frappe.qb.from_(ap)
.from_(cd)
.select(ap.name, ap.exempted_role)
.select(ap.name)
.where(
(ap.name == cd.parent)
& (ap.company == doc.company)
@@ -127,11 +126,6 @@ def validate_accounting_period_on_doc_save(doc, method=None):
).run(as_dict=1)
if accounting_period:
if (
accounting_period[0].get("exempted_role")
and accounting_period[0].get("exempted_role") in frappe.get_roles()
):
return
frappe.throw(
_("You cannot create a {0} within the closed Accounting Period {1}").format(
doc.doctype, frappe.bold(accounting_period[0]["name"])

View File

@@ -37,59 +37,6 @@ class TestAccountingPeriod(IntegrationTestCase):
doc = create_sales_invoice(do_not_save=1, cost_center="_Test Company - _TC", warehouse="Stores - _TC")
self.assertRaises(ClosedAccountingPeriod, doc.save)
def test_accounting_period_exempted_role(self):
# Create Accounting Period with exempted role
ap = create_accounting_period(
period_name="Test Accounting Period Exempted",
exempted_role="Accounts Manager",
start_date="2025-12-01",
end_date="2025-12-31",
)
ap.save()
# Create users
users = frappe.get_all("User", filters={"email": ["like", "test%"]}, limit=1)
user = None
if users[0].name:
user = frappe.get_doc("User", users[0].name)
else:
user = frappe.get_doc(
{
"doctype": "User",
"email": "test1@example.com",
"first_name": "Test1",
}
)
user.insert()
user.roles = []
user.append("roles", {"role": "Accounts User"})
# ---- Non-exempted user should FAIL ----
user.save(ignore_permissions=True)
frappe.clear_cache(user=user.name)
frappe.set_user(user.name)
posting_date = "2025-12-11"
doc = create_sales_invoice(
do_not_save=1,
posting_date=posting_date,
)
with self.assertRaises(frappe.ValidationError):
doc.submit()
# ---- Exempted role should PASS ----
user.append("roles", {"role": "Accounts Manager"})
user.save(ignore_permissions=True)
frappe.clear_cache(user=user.name)
doc = create_sales_invoice(do_not_save=1, posting_date=posting_date)
doc.submit() # Should not raise
self.assertEqual(doc.docstatus, 1)
def tearDown(self):
for d in frappe.get_all("Accounting Period"):
frappe.delete_doc("Accounting Period", d.name)
@@ -104,6 +51,5 @@ def create_accounting_period(**args):
accounting_period.company = args.company or "_Test Company"
accounting_period.period_name = args.period_name or "_Test_Period_Name_1"
accounting_period.append("closed_documents", {"document_type": "Sales Invoice", "closed": 1})
accounting_period.exempted_role = args.exempted_role or ""
return accounting_period

View File

@@ -64,17 +64,24 @@
"role_allowed_to_over_bill",
"credit_controller",
"make_payment_via_journal_entry",
"pos_tab",
"pos_setting_section",
"post_change_gl_entries",
"column_break_xrnd",
"assets_tab",
"asset_settings_section",
"calculate_depr_using_total_days",
"column_break_gjcc",
"book_asset_depreciation_entry_automatically",
"role_to_notify_on_depreciation_failure",
"closing_settings_tab",
"period_closing_settings_section",
"ignore_account_closing_balance",
"use_legacy_controller_for_pcv",
"column_break_25",
"tab_break_dpet",
"show_balance_in_coa",
"banking_tab",
"enable_party_matching",
"enable_fuzzy_matching",
"reports_tab",
"remarks_section",
"general_ledger_remarks_length",
@@ -82,20 +89,13 @@
"receivable_payable_remarks_length",
"accounts_receivable_payable_tuning_section",
"receivable_payable_fetch_method",
"default_ageing_range",
"column_break_ntmi",
"drop_ar_procedures",
"legacy_section",
"ignore_is_opening_check_for_reporting",
"tab_break_dpet",
"chart_of_accounts_section",
"show_balance_in_coa",
"banking_section",
"enable_party_matching",
"enable_fuzzy_matching",
"payment_request_section",
"payment_request_settings",
"create_pr_in_draft_status",
"budget_section",
"budget_settings",
"use_legacy_budget_controller"
],
"fields": [
@@ -279,6 +279,13 @@
"fieldname": "column_break_19",
"fieldtype": "Column Break"
},
{
"default": "1",
"description": "If enabled, ledger entries will be posted for change amount in POS transactions",
"fieldname": "post_change_gl_entries",
"fieldtype": "Check",
"label": "Create Ledger Entries for Change Amount"
},
{
"default": "0",
"description": "Learn about <a href=\"https://docs.erpnext.com/docs/v13/user/manual/en/accounts/articles/common_party_accounting#:~:text=Common%20Party%20Accounting%20in%20ERPNext,Invoice%20against%20a%20primary%20Supplier.\">Common Party</a>",
@@ -319,6 +326,11 @@
"fieldtype": "Tab Break",
"label": "Accounts Closing"
},
{
"fieldname": "pos_setting_section",
"fieldtype": "Section Break",
"label": "POS Setting"
},
{
"fieldname": "invoice_and_billing_tab",
"fieldtype": "Tab Break",
@@ -333,6 +345,11 @@
"fieldname": "column_break_17",
"fieldtype": "Column Break"
},
{
"fieldname": "pos_tab",
"fieldtype": "Tab Break",
"label": "POS"
},
{
"default": "0",
"description": "Enabling this will allow creation of multi-currency invoices against single party account in company currency",
@@ -343,7 +360,7 @@
{
"fieldname": "tab_break_dpet",
"fieldtype": "Tab Break",
"label": "Others"
"label": "Chart Of Accounts"
},
{
"default": "1",
@@ -387,6 +404,11 @@
"fieldtype": "Check",
"label": "Show Taxes as Table in Print"
},
{
"fieldname": "banking_tab",
"fieldtype": "Tab Break",
"label": "Banking"
},
{
"default": "0",
"description": "Auto match and set the Party in Bank Transactions",
@@ -462,9 +484,14 @@
"fieldtype": "Check",
"label": "Calculate daily depreciation using total days in depreciation period"
},
{
"description": "Payment Request created from Sales Order or Purchase Order will be in Draft status. When disabled document will be in unsaved state.",
"fieldname": "payment_request_settings",
"fieldtype": "Tab Break",
"label": "Payment Request"
},
{
"default": "1",
"description": "Payment Requests made from Sales / Purchase Invoice will be put in Draft explicitly",
"fieldname": "create_pr_in_draft_status",
"fieldtype": "Check",
"label": "Create in Draft Status"
@@ -506,6 +533,10 @@
"label": "Posting Date Inheritance for Exchange Gain / Loss",
"options": "Invoice\nPayment\nReconciliation Date"
},
{
"fieldname": "column_break_xrnd",
"fieldtype": "Column Break"
},
{
"default": "Buffered Cursor",
"fieldname": "receivable_payable_fetch_method",
@@ -545,6 +576,11 @@
"label": "Role Allowed to Override Stop Action",
"options": "Role"
},
{
"fieldname": "budget_settings",
"fieldtype": "Tab Break",
"label": "Budget"
},
{
"default": "1",
"description": "If enabled, user will be alerted before resetting posting date to current date in relevant transactions",
@@ -598,55 +634,15 @@
"fieldname": "use_legacy_budget_controller",
"fieldtype": "Check",
"label": "Use Legacy Budget Controller"
},
{
"default": "1",
"fieldname": "use_legacy_controller_for_pcv",
"fieldtype": "Check",
"label": "Use Legacy Controller For Period Closing Voucher"
},
{
"description": "Users with this role will be notified if the asset depreciation gets failed",
"fieldname": "role_to_notify_on_depreciation_failure",
"fieldtype": "Link",
"label": "Role to Notify on Depreciation Failure",
"options": "Role"
},
{
"default": "30, 60, 90, 120",
"fieldname": "default_ageing_range",
"fieldtype": "Data",
"label": "Default Ageing Range"
},
{
"fieldname": "chart_of_accounts_section",
"fieldtype": "Section Break",
"label": "Chart Of Accounts"
},
{
"fieldname": "banking_section",
"fieldtype": "Section Break",
"label": "Banking"
},
{
"fieldname": "payment_request_section",
"fieldtype": "Section Break",
"label": "Payment Request"
},
{
"fieldname": "budget_section",
"fieldtype": "Section Break",
"label": "Budget"
}
],
"grid_page_length": 50,
"hide_toolbar": 1,
"icon": "icon-cog",
"idx": 1,
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-01-11 18:30:45.968531",
"modified": "2025-09-24 16:08:08.515254",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Settings",

View File

@@ -40,7 +40,6 @@ class AccountsSettings(Document):
confirm_before_resetting_posting_date: DF.Check
create_pr_in_draft_status: DF.Check
credit_controller: DF.Link | None
default_ageing_range: DF.Data | None
delete_linked_ledger_entries: DF.Check
determine_address_tax_category_from: DF.Literal["Billing Address", "Shipping Address"]
enable_common_party_accounting: DF.Check
@@ -57,11 +56,11 @@ class AccountsSettings(Document):
make_payment_via_journal_entry: DF.Check
merge_similar_account_heads: DF.Check
over_billing_allowance: DF.Currency
post_change_gl_entries: DF.Check
receivable_payable_fetch_method: DF.Literal["Buffered Cursor", "UnBuffered Cursor", "Raw SQL"]
receivable_payable_remarks_length: DF.Int
reconciliation_queue_size: DF.Int
role_allowed_to_over_bill: DF.Link | None
role_to_notify_on_depreciation_failure: DF.Link | None
role_to_override_stop_action: DF.Link | None
round_row_wise_tax: DF.Check
show_balance_in_coa: DF.Check
@@ -73,7 +72,6 @@ class AccountsSettings(Document):
unlink_advance_payment_on_cancelation_of_order: DF.Check
unlink_payment_on_cancellation_of_invoice: DF.Check
use_legacy_budget_controller: DF.Check
use_legacy_controller_for_pcv: DF.Check
# end: auto-generated types
def validate(self):
@@ -152,5 +150,6 @@ class AccountsSettings(Document):
def drop_ar_sql_procedures(self):
from erpnext.accounts.report.accounts_receivable.accounts_receivable import InitSQLProceduresForAR
frappe.db.sql(f"drop function if exists {InitSQLProceduresForAR.genkey_function_name}")
frappe.db.sql(f"drop procedure if exists {InitSQLProceduresForAR.init_procedure_name}")
frappe.db.sql(f"drop procedure if exists {InitSQLProceduresForAR.allocate_procedure_name}")

View File

@@ -1,9 +1,8 @@
// Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
frappe.ui.form.on("Advance Payment Ledger Entry", {
refresh(frm) {
frm.set_currency_labels(["amount"], frm.doc.currency);
frm.set_currency_labels(["base_amount"], erpnext.get_currency(frm.doc.company));
},
});
// frappe.ui.form.on("Advance Payment Ledger Entry", {
// refresh(frm) {
// },
// });

View File

@@ -10,10 +10,8 @@
"voucher_no",
"against_voucher_type",
"against_voucher_no",
"currency",
"exchange_rate",
"amount",
"base_amount",
"currency",
"event",
"delinked"
],
@@ -78,29 +76,13 @@
"fieldtype": "Check",
"label": "DeLinked",
"read_only": 1
},
{
"depends_on": "base_amount",
"fieldname": "base_amount",
"fieldtype": "Currency",
"label": "Amount (Company Currency)",
"options": "Company:company:default_currency",
"read_only": 1
},
{
"depends_on": "exchange_rate",
"fieldname": "exchange_rate",
"fieldtype": "Float",
"label": "Exchange Rate",
"precision": "9",
"read_only": 1
}
],
"grid_page_length": 50,
"in_create": 1,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-11-13 12:45:03.014555",
"modified": "2025-10-13 15:11:58.300836",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Advance Payment Ledger Entry",

View File

@@ -19,12 +19,10 @@ class AdvancePaymentLedgerEntry(Document):
against_voucher_no: DF.DynamicLink | None
against_voucher_type: DF.Link | None
amount: DF.Currency
base_amount: DF.Currency
company: DF.Link | None
currency: DF.Link | None
delinked: DF.Check
event: DF.Data | None
exchange_rate: DF.Float
voucher_no: DF.DynamicLink | None
voucher_type: DF.Link | None
# end: auto-generated types

View File

@@ -0,0 +1,57 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2021-11-25 10:24:39.836195",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"reference_type",
"reference_name",
"reference_detail",
"account_head",
"allocated_amount"
],
"fields": [
{
"fieldname": "reference_type",
"fieldtype": "Link",
"label": "Reference Type",
"options": "DocType"
},
{
"fieldname": "reference_name",
"fieldtype": "Dynamic Link",
"label": "Reference Name",
"options": "reference_type"
},
{
"fieldname": "reference_detail",
"fieldtype": "Data",
"label": "Reference Detail"
},
{
"fieldname": "account_head",
"fieldtype": "Link",
"label": "Account Head",
"options": "Account"
},
{
"fieldname": "allocated_amount",
"fieldtype": "Currency",
"label": "Allocated Amount",
"options": "party_account_currency"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2024-03-27 13:05:58.308002",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Advance Tax",
"owner": "Administrator",
"permissions": [],
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View File

@@ -1,11 +1,11 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class BudgetDistribution(Document):
class AdvanceTax(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
@@ -14,13 +14,14 @@ class BudgetDistribution(Document):
if TYPE_CHECKING:
from frappe.types import DF
amount: DF.Currency
end_date: DF.Date | None
account_head: DF.Link | None
allocated_amount: DF.Currency
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
percent: DF.Percent
start_date: DF.Date | None
reference_detail: DF.Data | None
reference_name: DF.DynamicLink | None
reference_type: DF.Link | None
# end: auto-generated types
pass

View File

@@ -14,7 +14,6 @@
"description",
"included_in_paid_amount",
"set_by_item_tax_template",
"is_tax_withholding_account",
"accounting_dimensions_section",
"cost_center",
"dimension_col_break",
@@ -26,6 +25,7 @@
"net_amount",
"tax_amount",
"total",
"allocated_amount",
"column_break_13",
"base_tax_amount",
"base_net_amount",
@@ -97,11 +97,11 @@
"fieldtype": "Column Break"
},
{
"allow_on_submit": 1,
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
"allow_on_submit": 1,
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
},
{
"fieldname": "section_break_8",
@@ -172,6 +172,12 @@
"fieldtype": "Check",
"label": "Considered In Paid Amount"
},
{
"fieldname": "allocated_amount",
"fieldtype": "Currency",
"label": "Allocated Amount",
"options": "currency"
},
{
"fetch_from": "account_head.account_currency",
"fieldname": "currency",
@@ -207,26 +213,18 @@
"print_hide": 1,
"read_only": 1,
"report_hide": 1
},
{
"default": "0",
"fieldname": "is_tax_withholding_account",
"fieldtype": "Check",
"label": "Is Tax Withholding Account",
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-12-15 06:42:18.707671",
"modified": "2024-11-22 19:16:22.346267",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Advance Taxes and Charges",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "ASC",
"states": []
}
}

View File

@@ -17,6 +17,7 @@ class AdvanceTaxesandCharges(Document):
account_head: DF.Link
add_deduct_tax: DF.Literal["Add", "Deduct"]
allocated_amount: DF.Currency
base_net_amount: DF.Currency
base_tax_amount: DF.Currency
base_total: DF.Currency
@@ -27,12 +28,10 @@ class AdvanceTaxesandCharges(Document):
currency: DF.Link | None
description: DF.SmallText
included_in_paid_amount: DF.Check
is_tax_withholding_account: DF.Check
net_amount: DF.Currency
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
project: DF.Link | None
rate: DF.Float
row_id: DF.Data | None
set_by_item_tax_template: DF.Check

View File

@@ -125,7 +125,7 @@ class BankClearance(Document):
)
msg += "</ul>"
msgprint(_(msg))
frappe.throw(_(msg))
return
if not entries_to_update:
@@ -134,44 +134,16 @@ class BankClearance(Document):
for d in entries_to_update:
if d.payment_document == "Sales Invoice":
old_clearance_date = frappe.db.get_value(
frappe.db.set_value(
"Sales Invoice Payment",
{
"parent": d.payment_entry,
"account": self.account,
"amount": [">", 0],
},
{"parent": d.payment_entry, "account": self.get("account"), "amount": [">", 0]},
"clearance_date",
d.clearance_date,
)
if d.clearance_date or old_clearance_date:
frappe.db.set_value(
"Sales Invoice Payment",
{"parent": d.payment_entry, "account": self.get("account"), "amount": [">", 0]},
"clearance_date",
d.clearance_date,
)
sales_invoice = frappe.get_lazy_doc("Sales Invoice", d.payment_entry)
sales_invoice.add_comment(
"Comment",
_("Clearance date changed from {0} to {1} via Bank Clearance Tool").format(
old_clearance_date, d.clearance_date
),
)
else:
# using db_set to trigger notification
payment_entry = frappe.get_lazy_doc(d.payment_document, d.payment_entry)
old_clearance_date = payment_entry.clearance_date
if d.clearance_date or old_clearance_date:
# using db_set to trigger notification
payment_entry.db_set("clearance_date", d.clearance_date)
payment_entry.add_comment(
"Comment",
_("Clearance date changed from {0} to {1} via Bank Clearance Tool").format(
old_clearance_date, d.clearance_date
),
)
payment_entry.db_set("clearance_date", d.clearance_date)
self.get_payment_entries()
msgprint(_("Clearance Date updated"))

View File

@@ -30,7 +30,8 @@
"label": "Payment Entry",
"oldfieldname": "voucher_id",
"oldfieldtype": "Link",
"options": "payment_document"
"options": "payment_document",
"width": "50"
},
{
"columns": 2,
@@ -68,7 +69,7 @@
"read_only": 1
},
{
"columns": 1,
"columns": 2,
"fieldname": "cheque_number",
"fieldtype": "Data",
"in_list_view": 1,
@@ -78,10 +79,8 @@
"read_only": 1
},
{
"columns": 2,
"fieldname": "cheque_date",
"fieldtype": "Date",
"in_list_view": 1,
"label": "Cheque Date",
"oldfieldname": "cheque_date",
"oldfieldtype": "Date",
@@ -97,19 +96,17 @@
"oldfieldtype": "Date"
}
],
"grid_page_length": 50,
"idx": 1,
"istable": 1,
"links": [],
"modified": "2025-12-17 14:33:45.913311",
"modified": "2024-03-27 13:06:37.609319",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Bank Clearance Detail",
"owner": "Administrator",
"permissions": [],
"quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "ASC",
"states": []
}
}

View File

@@ -9,6 +9,13 @@ cur_frm.add_fetch("bank", "swift_number", "swift_number");
frappe.ui.form.on("Bank Guarantee", {
setup: function (frm) {
frm.set_query("bank", function () {
return {
filters: {
company: frm.doc.company,
},
};
});
frm.set_query("bank_account", function () {
return {
filters: {

View File

@@ -304,7 +304,6 @@ def create_payment_entry_bts(
project=None,
cost_center=None,
allow_edit=None,
company_bank_account=None,
):
# Create a new payment entry based on the bank transaction
bank_transaction = frappe.db.get_values(
@@ -346,9 +345,6 @@ def create_payment_entry_bts(
pe.project = project
pe.cost_center = cost_center
if company_bank_account:
pe.bank_account = company_bank_account
pe.validate()
if allow_edit:

View File

@@ -14,7 +14,6 @@ import openpyxl
from frappe import _
from frappe.core.doctype.data_import.data_import import DataImport
from frappe.core.doctype.data_import.importer import Importer, ImportFile
from frappe.query_builder.functions import Count
from frappe.utils.background_jobs import enqueue
from frappe.utils.file_manager import get_file, save_file
from frappe.utils.xlsxutils import ILLEGAL_CHARACTERS_RE, handle_html
@@ -372,7 +371,7 @@ def get_import_status(docname):
logs = frappe.get_all(
"Data Import Log",
fields=[{"COUNT": "*", "as": "count"}, "success"],
fields=["count(*) as count", "success"],
filters={"data_import": docname},
group_by="success",
)

View File

@@ -38,10 +38,7 @@
"column_break_3czf",
"bank_party_name",
"bank_party_account_number",
"bank_party_iban",
"extended_bank_statement_section",
"included_fee",
"excluded_fee"
"bank_party_iban"
],
"fields": [
{
@@ -236,32 +233,12 @@
{
"fieldname": "column_break_oufv",
"fieldtype": "Column Break"
},
{
"fieldname": "extended_bank_statement_section",
"fieldtype": "Section Break",
"label": "Extended Bank Statement"
},
{
"fieldname": "included_fee",
"fieldtype": "Currency",
"label": "Included Fee",
"non_negative": 1,
"options": "currency"
},
{
"description": "On save, the Excluded Fee will be converted to an Included Fee.",
"fieldname": "excluded_fee",
"fieldtype": "Currency",
"label": "Excluded Fee",
"non_negative": 1,
"options": "currency"
}
],
"grid_page_length": 50,
"is_submittable": 1,
"links": [],
"modified": "2025-12-07 20:49:18.600757",
"modified": "2025-10-23 17:32:58.514807",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Bank Transaction",

View File

@@ -32,8 +32,6 @@ class BankTransaction(Document):
date: DF.Date | None
deposit: DF.Currency
description: DF.SmallText | None
excluded_fee: DF.Currency
included_fee: DF.Currency
naming_series: DF.Literal["ACC-BTN-.YYYY.-"]
party: DF.DynamicLink | None
party_type: DF.Link | None
@@ -47,14 +45,9 @@ class BankTransaction(Document):
# end: auto-generated types
def before_validate(self):
self.handle_excluded_fee()
self.update_allocated_amount()
def on_discard(self):
self.db_set("status", "Cancelled")
def validate(self):
self.validate_included_fee()
self.validate_duplicate_references()
self.validate_currency()
@@ -314,40 +307,6 @@ class BankTransaction(Document):
self.party_type, self.party = result
def validate_included_fee(self):
"""
The included_fee is only handled for withdrawals. An included_fee for a deposit, is not credited to the account and is
therefore outside of the deposit value and can be larger than the deposit itself.
"""
if self.included_fee and self.withdrawal:
if self.included_fee > self.withdrawal:
frappe.throw(_("Included fee is bigger than the withdrawal itself."))
def handle_excluded_fee(self):
# Include the excluded fee on validate to handle all further processing the same
excluded_fee = flt(self.excluded_fee)
if excluded_fee <= 0:
return
# Suppress a negative deposit (aka withdrawal), likely not intendend
if flt(self.deposit) > 0 and (flt(self.deposit) - excluded_fee) < 0:
frappe.throw(_("The Excluded Fee is bigger than the Deposit it is deducted from."))
# Enforce directionality
if flt(self.deposit) > 0 and flt(self.withdrawal) > 0:
frappe.throw(
_("Only one of Deposit or Withdrawal should be non-zero when applying an Excluded Fee.")
)
if flt(self.deposit) > 0:
self.deposit = flt(self.deposit) - excluded_fee
# A fee applied to deposit and withdrawal equal 0 become a withdrawal
elif flt(self.withdrawal) >= 0:
self.withdrawal = flt(self.withdrawal) + excluded_fee
self.included_fee = flt(self.included_fee) + excluded_fee
self.excluded_fee = 0
@frappe.whitelist()
def get_doctypes_for_bank_reconciliation():

View File

@@ -1,133 +0,0 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.tests import UnitTestCase
class TestBankTransactionFees(UnitTestCase):
def test_included_fee_throws(self):
"""A fee that's part of a withdrawal cannot be bigger than the
withdrawal itself."""
bt = frappe.new_doc("Bank Transaction")
bt.withdrawal = 100
bt.included_fee = 101
self.assertRaises(frappe.ValidationError, bt.validate_included_fee)
def test_included_fee_allows_equal(self):
"""A fee that's part of a withdrawal may be equal to the withdrawal
amount (only the fee was deducted from the account)."""
bt = frappe.new_doc("Bank Transaction")
bt.withdrawal = 100
bt.included_fee = 100
bt.validate_included_fee()
def test_included_fee_allows_for_deposit(self):
"""For deposits, a fee may be recorded separately without limiting the
received amount."""
bt = frappe.new_doc("Bank Transaction")
bt.deposit = 10
bt.included_fee = 999
bt.validate_included_fee()
def test_excluded_fee_noop_when_zero(self):
"""When there is no excluded fee to apply, the amounts should remain
unchanged."""
bt = frappe.new_doc("Bank Transaction")
bt.deposit = 100
bt.withdrawal = 0
bt.included_fee = 5
bt.excluded_fee = 0
bt.handle_excluded_fee()
self.assertEqual(bt.deposit, 100)
self.assertEqual(bt.withdrawal, 0)
self.assertEqual(bt.included_fee, 5)
self.assertEqual(bt.excluded_fee, 0)
def test_excluded_fee_throws_when_exceeds_deposit(self):
"""A fee deducted from an incoming payment must not exceed the incoming
amount (else it would be a withdrawal, a conversion we don't support)."""
bt = frappe.new_doc("Bank Transaction")
bt.deposit = 10
bt.excluded_fee = 11
self.assertRaises(frappe.ValidationError, bt.handle_excluded_fee)
def test_excluded_fee_throws_when_both_deposit_and_withdrawal_are_set(self):
"""A transaction must be either incoming or outgoing when applying a
fee, not both."""
bt = frappe.new_doc("Bank Transaction")
bt.deposit = 10
bt.withdrawal = 10
bt.excluded_fee = 1
self.assertRaises(frappe.ValidationError, bt.handle_excluded_fee)
def test_excluded_fee_deducts_from_deposit(self):
"""When a fee is deducted from an incoming payment, the net received
amount decreases and the fee is tracked as included."""
bt = frappe.new_doc("Bank Transaction")
bt.deposit = 100
bt.withdrawal = 0
bt.included_fee = 2
bt.excluded_fee = 5
bt.handle_excluded_fee()
self.assertEqual(bt.deposit, 95)
self.assertEqual(bt.withdrawal, 0)
self.assertEqual(bt.included_fee, 7)
self.assertEqual(bt.excluded_fee, 0)
def test_excluded_fee_can_reduce_an_incoming_payment_to_zero(self):
"""A separately-deducted fee may reduce an incoming payment to zero,
while still tracking the fee."""
bt = frappe.new_doc("Bank Transaction")
bt.deposit = 5
bt.withdrawal = 0
bt.included_fee = 0
bt.excluded_fee = 5
bt.handle_excluded_fee()
self.assertEqual(bt.deposit, 0)
self.assertEqual(bt.withdrawal, 0)
self.assertEqual(bt.included_fee, 5)
self.assertEqual(bt.excluded_fee, 0)
def test_excluded_fee_increases_outgoing_payment(self):
"""When a separately-deducted fee is provided for an outgoing payment,
the total money leaving increases and the fee is tracked."""
bt = frappe.new_doc("Bank Transaction")
bt.deposit = 0
bt.withdrawal = 100
bt.included_fee = 2
bt.excluded_fee = 5
bt.handle_excluded_fee()
self.assertEqual(bt.deposit, 0)
self.assertEqual(bt.withdrawal, 105)
self.assertEqual(bt.included_fee, 7)
self.assertEqual(bt.excluded_fee, 0)
def test_excluded_fee_turns_zero_amount_into_withdrawal(self):
"""If only an excluded fee is provided, it should be treated as an
outgoing payment and the fee is then tracked as included."""
bt = frappe.new_doc("Bank Transaction")
bt.deposit = 0
bt.withdrawal = 0
bt.included_fee = 0
bt.excluded_fee = 5
bt.handle_excluded_fee()
self.assertEqual(bt.deposit, 0)
self.assertEqual(bt.withdrawal, 5)
self.assertEqual(bt.included_fee, 5)
self.assertEqual(bt.excluded_fee, 0)

View File

@@ -4,19 +4,20 @@ frappe.provide("erpnext.accounts.dimensions");
frappe.ui.form.on("Budget", {
onload: function (frm) {
frm.set_query("monthly_distribution", function () {
frm.set_query("account", "accounts", function () {
return {
filters: {
fiscal_year: frm.doc.fiscal_year,
company: frm.doc.company,
report_type: "Profit and Loss",
is_group: 0,
},
};
});
frm.set_query("account", function () {
frm.set_query("monthly_distribution", function () {
return {
filters: {
is_group: 0,
company: frm.doc.company,
fiscal_year: frm.doc.fiscal_year,
},
};
});
@@ -29,20 +30,8 @@ frappe.ui.form.on("Budget", {
});
},
refresh: async function (frm) {
refresh: function (frm) {
frm.trigger("toggle_reqd_fields");
if (!frm.doc.__islocal && frm.doc.docstatus == 1) {
frm.add_custom_button(
__("Revise Budget"),
function () {
frm.events.revise_budget_action(frm);
},
__("Actions")
);
}
toggle_distribution_fields(frm);
},
budget_against: function (frm) {
@@ -50,20 +39,6 @@ frappe.ui.form.on("Budget", {
frm.trigger("toggle_reqd_fields");
},
budget_amount(frm) {
if (frm.doc.budget_distribution?.length) {
frm.doc.budget_distribution.forEach((row) => {
row.amount = flt((row.percent / 100) * frm.doc.budget_amount, 2);
});
set_total_budget_amount(frm);
frm.refresh_field("budget_distribution");
}
},
distribute_equally: function (frm) {
toggle_distribution_fields(frm);
},
set_null_value: function (frm) {
if (frm.doc.budget_against == "Cost Center") {
frm.set_value("project", null);
@@ -76,68 +51,4 @@ frappe.ui.form.on("Budget", {
frm.toggle_reqd("cost_center", frm.doc.budget_against == "Cost Center");
frm.toggle_reqd("project", frm.doc.budget_against == "Project");
},
revise_budget_action: function (frm) {
frappe.confirm(
__(
"Are you sure you want to revise this budget? The current budget will be cancelled and a new draft will be created."
),
function () {
frappe.call({
method: "erpnext.accounts.doctype.budget.budget.revise_budget",
args: { budget_name: frm.doc.name },
callback: function (r) {
if (r.message) {
frappe.msgprint(__("New revised budget created successfully"));
frappe.set_route("Form", "Budget", r.message);
}
},
});
},
function () {
frappe.msgprint(__("Revision cancelled"));
}
);
},
});
frappe.ui.form.on("Budget Distribution", {
amount(frm, cdt, cdn) {
let row = frappe.get_doc(cdt, cdn);
if (frm.doc.budget_amount) {
row.percent = flt((row.amount / frm.doc.budget_amount) * 100, 2);
set_total_budget_amount(frm);
frm.refresh_field("budget_distribution");
}
},
percent(frm, cdt, cdn) {
let row = frappe.get_doc(cdt, cdn);
if (frm.doc.budget_amount) {
row.amount = flt((row.percent / 100) * frm.doc.budget_amount, 2);
set_total_budget_amount(frm);
frm.refresh_field("budget_distribution");
}
},
});
function set_total_budget_amount(frm) {
let total = 0;
(frm.doc.budget_distribution || []).forEach((row) => {
total += flt(row.amount);
});
frm.set_value("budget_distribution_total", total);
}
function toggle_distribution_fields(frm) {
const grid = frm.fields_dict.budget_distribution.grid;
["amount", "percent"].forEach((field) => {
grid.update_docfield_property(field, "read_only", frm.doc.distribute_equally);
});
grid.refresh();
}

View File

@@ -12,23 +12,10 @@
"company",
"cost_center",
"project",
"account",
"fiscal_year",
"column_break_3",
"monthly_distribution",
"amended_from",
"from_fiscal_year",
"to_fiscal_year",
"budget_start_date",
"budget_end_date",
"distribution_frequency",
"budget_amount",
"section_break_nwug",
"distribute_equally",
"section_break_fpdt",
"budget_distribution",
"section_break_wkqb",
"column_break_paum",
"column_break_nwor",
"budget_distribution_total",
"section_break_6",
"applicable_on_material_request",
"action_if_annual_budget_exceeded_on_mr",
@@ -45,8 +32,8 @@
"applicable_on_cumulative_expense",
"action_if_annual_exceeded_on_cumulative_expense",
"action_if_accumulated_monthly_exceeded_on_cumulative_expense",
"section_break_kkan",
"revision_of"
"section_break_21",
"accounts"
],
"fields": [
{
@@ -57,7 +44,6 @@
"in_standard_filter": 1,
"label": "Budget Against",
"options": "\nCost Center\nProject",
"read_only_depends_on": "eval: doc.revision_of",
"reqd": 1
},
{
@@ -67,7 +53,6 @@
"in_standard_filter": 1,
"label": "Company",
"options": "Company",
"read_only_depends_on": "eval: doc.revision_of",
"reqd": 1
},
{
@@ -77,8 +62,7 @@
"in_global_search": 1,
"in_standard_filter": 1,
"label": "Cost Center",
"options": "Cost Center",
"read_only_depends_on": "eval: doc.revision_of"
"options": "Cost Center"
},
{
"depends_on": "eval:doc.budget_against == 'Project'",
@@ -86,13 +70,28 @@
"fieldtype": "Link",
"in_standard_filter": 1,
"label": "Project",
"options": "Project",
"read_only_depends_on": "eval: doc.revision_of"
"options": "Project"
},
{
"fieldname": "fiscal_year",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Fiscal Year",
"options": "Fiscal Year",
"reqd": 1
},
{
"fieldname": "column_break_3",
"fieldtype": "Column Break"
},
{
"depends_on": "eval:in_list([\"Stop\", \"Warn\"], doc.action_if_accumulated_monthly_budget_exceeded_on_po || doc.action_if_accumulated_monthly_budget_exceeded_on_mr || doc.action_if_accumulated_monthly_budget_exceeded_on_actual)",
"fieldname": "monthly_distribution",
"fieldtype": "Link",
"label": "Monthly Distribution",
"options": "Monthly Distribution"
},
{
"fieldname": "amended_from",
"fieldtype": "Link",
@@ -188,12 +187,22 @@
"options": "\nStop\nWarn\nIgnore"
},
{
"default": "BUDGET-.########",
"fieldname": "section_break_21",
"fieldtype": "Section Break"
},
{
"fieldname": "accounts",
"fieldtype": "Table",
"label": "Budget Accounts",
"options": "Budget Account",
"reqd": 1
},
{
"fieldname": "naming_series",
"fieldtype": "Select",
"label": "Series",
"no_copy": 1,
"options": "BUDGET-.########",
"options": "BUDGET-.YYYY.-",
"print_hide": 1,
"reqd": 1,
"set_only_once": 1
@@ -223,117 +232,13 @@
"fieldtype": "Select",
"label": "Action if Accumulative Monthly Budget Exceeded on Cumulative Expense",
"options": "\nStop\nWarn\nIgnore"
},
{
"fieldname": "section_break_fpdt",
"fieldtype": "Section Break",
"hide_border": 1
},
{
"fieldname": "budget_distribution",
"fieldtype": "Table",
"label": "Budget Distribution",
"options": "Budget Distribution"
},
{
"fieldname": "account",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Account",
"options": "Account",
"read_only_depends_on": "eval: doc.revision_of",
"reqd": 1
},
{
"fieldname": "budget_amount",
"fieldtype": "Currency",
"label": "Budget Amount",
"reqd": 1
},
{
"fieldname": "section_break_kkan",
"fieldtype": "Section Break"
},
{
"fieldname": "revision_of",
"fieldtype": "Data",
"label": "Revision Of",
"no_copy": 1,
"read_only": 1
},
{
"default": "1",
"fieldname": "distribute_equally",
"fieldtype": "Check",
"label": "Distribute Equally"
},
{
"fieldname": "section_break_nwug",
"fieldtype": "Section Break",
"hide_border": 1
},
{
"fieldname": "from_fiscal_year",
"fieldtype": "Link",
"label": "From Fiscal Year",
"options": "Fiscal Year",
"read_only_depends_on": "eval: doc.revision_of",
"reqd": 1
},
{
"fieldname": "to_fiscal_year",
"fieldtype": "Link",
"label": "To Fiscal Year",
"options": "Fiscal Year",
"read_only_depends_on": "eval: doc.revision_of",
"reqd": 1
},
{
"fieldname": "budget_start_date",
"fieldtype": "Date",
"hidden": 1,
"label": "Budget Start Date"
},
{
"fieldname": "budget_end_date",
"fieldtype": "Date",
"hidden": 1,
"label": "Budget End Date"
},
{
"default": "Monthly",
"fieldname": "distribution_frequency",
"fieldtype": "Select",
"label": "Distribution Frequency",
"options": "Monthly\nQuarterly\nHalf-Yearly\nYearly",
"read_only_depends_on": "eval: doc.revision_of",
"reqd": 1
},
{
"fieldname": "section_break_wkqb",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_paum",
"fieldtype": "Column Break"
},
{
"fieldname": "column_break_nwor",
"fieldtype": "Column Break"
},
{
"fieldname": "budget_distribution_total",
"fieldtype": "Currency",
"label": "Budget Distribution Total",
"no_copy": 1,
"read_only": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2025-12-10 02:35:01.197613",
"modified": "2025-06-16 15:57:13.114981",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Budget",

View File

@@ -2,14 +2,10 @@
# For license information, please see license.txt
from datetime import date
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.query_builder.functions import Sum
from frappe.utils import add_months, flt, fmt_money, get_last_day, getdate, month_diff
from frappe.utils.data import get_first_day, nowdate
from frappe.utils import add_months, flt, fmt_money, get_last_day, getdate
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
@@ -34,9 +30,9 @@ class Budget(Document):
if TYPE_CHECKING:
from frappe.types import DF
from erpnext.accounts.doctype.budget_distribution.budget_distribution import BudgetDistribution
from erpnext.accounts.doctype.budget_account.budget_account import BudgetAccount
account: DF.Link
accounts: DF.Table[BudgetAccount]
action_if_accumulated_monthly_budget_exceeded: DF.Literal["", "Stop", "Warn", "Ignore"]
action_if_accumulated_monthly_budget_exceeded_on_mr: DF.Literal["", "Stop", "Warn", "Ignore"]
action_if_accumulated_monthly_budget_exceeded_on_po: DF.Literal["", "Stop", "Warn", "Ignore"]
@@ -51,118 +47,73 @@ class Budget(Document):
applicable_on_material_request: DF.Check
applicable_on_purchase_order: DF.Check
budget_against: DF.Literal["", "Cost Center", "Project"]
budget_amount: DF.Currency
budget_distribution: DF.Table[BudgetDistribution]
budget_distribution_total: DF.Currency
budget_end_date: DF.Date | None
budget_start_date: DF.Date | None
company: DF.Link
cost_center: DF.Link | None
distribute_equally: DF.Check
distribution_frequency: DF.Literal["Monthly", "Quarterly", "Half-Yearly", "Yearly"]
from_fiscal_year: DF.Link
naming_series: DF.Literal["BUDGET-.########"]
fiscal_year: DF.Link
monthly_distribution: DF.Link | None
naming_series: DF.Literal["BUDGET-.YYYY.-"]
project: DF.Link | None
revision_of: DF.Data | None
to_fiscal_year: DF.Link
# end: auto-generated types
def validate(self):
if not self.get(frappe.scrub(self.budget_against)):
frappe.throw(_("{0} is mandatory").format(self.budget_against))
self.validate_budget_amount()
self.validate_fiscal_year()
self.set_fiscal_year_dates()
self.validate_duplicate()
self.validate_account()
self.validate_accounts()
self.set_null_value()
self.validate_applicable_for()
self.validate_existing_expenses()
def validate_budget_amount(self):
if self.budget_amount <= 0:
frappe.throw(_("Budget Amount can not be {0}.").format(self.budget_amount))
def validate_fiscal_year(self):
if self.from_fiscal_year:
self.validate_fiscal_year_company(self.from_fiscal_year, self.company)
if self.to_fiscal_year:
self.validate_fiscal_year_company(self.to_fiscal_year, self.company)
def validate_fiscal_year_company(self, fiscal_year, company):
linked_companies = frappe.get_all(
"Fiscal Year Company", filters={"parent": fiscal_year}, pluck="company"
)
if linked_companies and company not in linked_companies:
frappe.throw(_("Fiscal Year {0} is not available for Company {1}.").format(fiscal_year, company))
def set_fiscal_year_dates(self):
if self.from_fiscal_year:
self.budget_start_date = frappe.get_cached_value(
"Fiscal Year", self.from_fiscal_year, "year_start_date"
)
if self.to_fiscal_year:
self.budget_end_date = frappe.get_cached_value(
"Fiscal Year", self.to_fiscal_year, "year_end_date"
)
if self.budget_start_date > self.budget_end_date:
frappe.throw(_("From Fiscal Year cannot be greater than To Fiscal Year"))
def validate_duplicate(self):
budget_against_field = frappe.scrub(self.budget_against)
budget_against = self.get(budget_against_field)
account = self.account
if not account:
return
accounts = [d.account for d in self.accounts] or []
existing_budget = frappe.db.sql(
f"""
SELECT name, account
FROM `tabBudget`
WHERE
docstatus < 2
AND company = %s
AND {budget_against_field} = %s
AND account = %s
AND name != %s
AND (
(SELECT year_start_date FROM `tabFiscal Year` WHERE name = from_fiscal_year) <= %s
AND (SELECT year_end_date FROM `tabFiscal Year` WHERE name = to_fiscal_year) >= %s
)
""",
(self.company, budget_against, account, self.name, self.budget_end_date, self.budget_start_date),
as_dict=True,
"""
select
b.name, ba.account from `tabBudget` b, `tabBudget Account` ba
where
ba.parent = b.name and b.docstatus < 2 and b.company = {} and {}={} and
b.fiscal_year={} and b.name != {} and ba.account in ({}) """.format(
"%s", budget_against_field, "%s", "%s", "%s", ",".join(["%s"] * len(accounts))
),
(self.company, budget_against, self.fiscal_year, self.name, *tuple(accounts)),
as_dict=1,
)
if existing_budget:
d = existing_budget[0]
for d in existing_budget:
frappe.throw(
_(
"Another Budget record '{0}' already exists against {1} '{2}' and account '{3}' with overlapping fiscal years."
).format(d.name, self.budget_against, budget_against, d.account),
"Another Budget record '{0}' already exists against {1} '{2}' and account '{3}' for fiscal year {4}"
).format(d.name, self.budget_against, budget_against, d.account, self.fiscal_year),
DuplicateBudgetError,
)
def validate_account(self):
if not self.account:
frappe.throw(_("Account is mandatory"))
account_details = frappe.get_cached_value(
"Account", self.account, ["is_group", "company", "report_type"], as_dict=1
)
if account_details.is_group:
frappe.throw(_("Budget cannot be assigned against Group Account {0}").format(self.account))
elif account_details.company != self.company:
frappe.throw(_("Account {0} does not belong to company {1}").format(self.account, self.company))
elif account_details.report_type != "Profit and Loss":
frappe.throw(
_("Budget cannot be assigned against {0}, as it's not an Income or Expense account").format(
self.account
def validate_accounts(self):
account_list = []
for d in self.get("accounts"):
if d.account:
account_details = frappe.get_cached_value(
"Account", d.account, ["is_group", "company", "report_type"], as_dict=1
)
)
if account_details.is_group:
frappe.throw(_("Budget cannot be assigned against Group Account {0}").format(d.account))
elif account_details.company != self.company:
frappe.throw(
_("Account {0} does not belongs to company {1}").format(d.account, self.company)
)
elif account_details.report_type != "Profit and Loss":
frappe.throw(
_(
"Budget cannot be assigned against {0}, as it's not an Income or Expense account"
).format(d.account)
)
if d.account in account_list:
frappe.throw(_("Account {0} has been entered multiple times").format(d.account))
else:
account_list.append(d.account)
def set_null_value(self):
if self.budget_against == "Cost Center":
@@ -188,232 +139,30 @@ class Budget(Document):
):
self.applicable_on_booking_actual_expenses = 1
def validate_existing_expenses(self):
if self.is_new() and self.revision_of:
return
params = frappe._dict(
{
"company": self.company,
"account": self.account,
"budget_start_date": self.budget_start_date,
"budget_end_date": self.budget_end_date,
"budget_against_field": frappe.scrub(self.budget_against),
"budget_against_doctype": frappe.unscrub(self.budget_against),
}
)
params[params.budget_against_field] = self.get(params.budget_against_field)
if frappe.get_cached_value("DocType", params.budget_against_doctype, "is_tree"):
params.is_tree = True
else:
params.is_tree = False
actual_spent = get_actual_expense(params)
if actual_spent > self.budget_amount:
frappe.throw(
_(
"Spending for Account {0} ({1}) between {2} and {3} "
"has already exceeded the new allocated budget. "
"Spent: {4}, Budget: {5}"
).format(
frappe.bold(self.account),
frappe.bold(self.company),
frappe.bold(self.budget_start_date),
frappe.bold(self.budget_end_date),
frappe.bold(frappe.utils.fmt_money(actual_spent)),
frappe.bold(frappe.utils.fmt_money(self.budget_amount)),
),
title=_("Budget Limit Exceeded"),
)
def before_save(self):
self.allocate_budget()
self.budget_distribution_total = sum(flt(row.amount) for row in self.budget_distribution)
def on_update(self):
self.validate_distribution_totals()
def allocate_budget(self):
if self._should_skip_allocation():
return
if self._should_recalculate_manual_distribution():
self._recalculate_manual_distribution()
return
if not self.should_regenerate_budget_distribution():
return
self._regenerate_distribution()
def _should_skip_allocation(self):
return self.revision_of and not self.distribute_equally
def _should_recalculate_manual_distribution(self):
return (
not self.distribute_equally
and bool(self.budget_distribution)
and self._is_only_budget_amount_changed()
)
def _is_only_budget_amount_changed(self):
old = self.get_doc_before_save()
if not old:
return False
return (
old.budget_amount != self.budget_amount
and old.distribution_frequency == self.distribution_frequency
and old.budget_start_date == self.budget_start_date
and old.budget_end_date == self.budget_end_date
)
def _recalculate_manual_distribution(self):
for row in self.budget_distribution:
row.amount = flt((row.percent / 100) * self.budget_amount, 3)
def should_regenerate_budget_distribution(self):
"""Check whether budget distribution should be recalculated."""
old_doc = self.get_doc_before_save() if not self.is_new() else None
if not old_doc or not self.budget_distribution:
return True
if old_doc:
changed_fields = [
"from_fiscal_year",
"to_fiscal_year",
"budget_amount",
"distribution_frequency",
]
for field in changed_fields:
if old_doc.get(field) != self.get(field):
return True
return bool(self.distribute_equally)
def _regenerate_distribution(self):
self.set("budget_distribution", [])
periods = self.get_budget_periods()
total_periods = len(periods)
row_percent = 100 / total_periods if total_periods else 0
for start_date, end_date in periods:
row = self.append("budget_distribution", {})
row.start_date = start_date
row.end_date = end_date
self.add_allocated_amount(row, row_percent)
self.budget_distribution_total = self.budget_amount
def get_budget_periods(self):
"""Return list of (start_date, end_date) tuples based on frequency."""
frequency = self.distribution_frequency
periods = []
start_date = getdate(self.budget_start_date)
end_date = getdate(self.budget_end_date)
while start_date <= end_date:
period_start = get_first_day(start_date)
period_end = self.get_period_end(period_start, frequency)
period_end = min(period_end, end_date)
periods.append((period_start, period_end))
start_date = add_months(period_start, self.get_month_increment(frequency))
return periods
def get_period_end(self, start_date, frequency):
"""Return the correct end date for a given frequency."""
if frequency == "Monthly":
return get_last_day(start_date)
elif frequency == "Quarterly":
return get_last_day(add_months(start_date, 2))
elif frequency == "Half-Yearly":
return get_last_day(add_months(start_date, 5))
else: # Yearly
return get_last_day(add_months(start_date, 11))
def get_month_increment(self, frequency):
"""Return how many months to move forward for the next period."""
return {
"Monthly": 1,
"Quarterly": 3,
"Half-Yearly": 6,
"Yearly": 12,
}.get(frequency, 1)
def add_allocated_amount(self, row, row_percent):
row.amount = flt(self.budget_amount * row_percent / 100, 3)
row.percent = flt(row_percent, 3)
def validate_distribution_totals(self):
if self.should_regenerate_budget_distribution():
return
total_amount = sum(d.amount for d in self.budget_distribution)
total_percent = sum(d.percent for d in self.budget_distribution)
if flt(abs(total_amount - self.budget_amount), 2) > 0.10:
frappe.throw(
_("Total distributed amount {0} must be equal to Budget Amount {1}").format(
flt(total_amount, 2), self.budget_amount
)
)
if flt(abs(total_percent - 100), 2) > 0.10:
frappe.throw(
_("Total distribution percent must equal 100 (currently {0})").format(round(total_percent, 2))
)
def validate_expense_against_budget(params, expense_amount=0):
params = frappe._dict(params)
def validate_expense_against_budget(args, expense_amount=0):
args = frappe._dict(args)
if not frappe.db.count("Budget", cache=True):
return
if not params.fiscal_year:
params.fiscal_year = get_fiscal_year(params.get("posting_date"), company=params.get("company"))[0]
if not args.fiscal_year:
args.fiscal_year = get_fiscal_year(args.get("posting_date"), company=args.get("company"))[0]
posting_date = getdate(params.get("posting_date"))
posting_fiscal_year = get_fiscal_year(posting_date, company=params.get("company"))[0]
year_start_date, year_end_date = get_fiscal_year_date_range(posting_fiscal_year, posting_fiscal_year)
budget_exists = frappe.db.sql(
"""
select name
from `tabBudget`
where company = %s
and docstatus = 1
and (SELECT year_start_date FROM `tabFiscal Year` WHERE name = from_fiscal_year) <= %s
and (SELECT year_end_date FROM `tabFiscal Year` WHERE name = to_fiscal_year) >= %s
limit 1
""",
(params.company, year_end_date, year_start_date),
)
if not budget_exists:
return
if params.get("company"):
if args.get("company"):
frappe.flags.exception_approver_role = frappe.get_cached_value(
"Company", params.get("company"), "exception_budget_approver_role"
"Company", args.get("company"), "exception_budget_approver_role"
)
if not params.account:
params.account = params.get("expense_account")
if not frappe.db.get_value("Budget", {"fiscal_year": args.fiscal_year, "company": args.company}):
return
if not params.get("expense_account") and params.get("account"):
params.expense_account = params.account
if not args.account:
args.account = args.get("expense_account")
if not (params.get("account") and params.get("cost_center")) and params.item_code:
params.cost_center, params.account = get_item_details(params)
if not (args.get("account") and args.get("cost_center")) and args.item_code:
args.cost_center, args.account = get_item_details(args)
if not params.account:
if not args.account:
return
default_dimensions = [
@@ -431,78 +180,59 @@ def validate_expense_against_budget(params, expense_amount=0):
budget_against = dimension.get("fieldname")
if (
params.get(budget_against)
and params.account
and (frappe.get_cached_value("Account", params.account, "root_type") == "Expense")
args.get(budget_against)
and args.account
and (frappe.get_cached_value("Account", args.account, "root_type") == "Expense")
):
doctype = dimension.get("document_type")
if frappe.get_cached_value("DocType", doctype, "is_tree"):
lft, rgt = frappe.get_cached_value(doctype, params.get(budget_against), ["lft", "rgt"])
lft, rgt = frappe.get_cached_value(doctype, args.get(budget_against), ["lft", "rgt"])
condition = f"""and exists(select name from `tab{doctype}`
where lft<={lft} and rgt>={rgt} and name=b.{budget_against})""" # nosec
params.is_tree = True
args.is_tree = True
else:
condition = f"and b.{budget_against}={frappe.db.escape(params.get(budget_against))}"
params.is_tree = False
condition = f"and b.{budget_against}={frappe.db.escape(args.get(budget_against))}"
args.is_tree = False
params.budget_against_field = budget_against
params.budget_against_doctype = doctype
args.budget_against_field = budget_against
args.budget_against_doctype = doctype
budget_records = frappe.db.sql(
f"""
SELECT
b.name,
b.{budget_against} AS budget_against,
b.budget_amount,
b.from_fiscal_year,
b.to_fiscal_year,
b.budget_start_date,
b.budget_end_date,
IFNULL(b.applicable_on_material_request, 0) AS for_material_request,
IFNULL(b.applicable_on_purchase_order, 0) AS for_purchase_order,
IFNULL(b.applicable_on_booking_actual_expenses, 0) AS for_actual_expenses,
b.action_if_annual_budget_exceeded,
b.action_if_accumulated_monthly_budget_exceeded,
b.action_if_annual_budget_exceeded_on_mr,
b.action_if_accumulated_monthly_budget_exceeded_on_mr,
b.action_if_annual_budget_exceeded_on_po,
b.action_if_accumulated_monthly_budget_exceeded_on_po
FROM
`tabBudget` b
WHERE
b.company = %s
AND b.docstatus = 1
AND %s BETWEEN b.budget_start_date AND b.budget_end_date
AND b.account = %s
select
b.{budget_against} as budget_against, ba.budget_amount, b.monthly_distribution,
ifnull(b.applicable_on_material_request, 0) as for_material_request,
ifnull(applicable_on_purchase_order, 0) as for_purchase_order,
ifnull(applicable_on_booking_actual_expenses,0) as for_actual_expenses,
b.action_if_annual_budget_exceeded, b.action_if_accumulated_monthly_budget_exceeded,
b.action_if_annual_budget_exceeded_on_mr, b.action_if_accumulated_monthly_budget_exceeded_on_mr,
b.action_if_annual_budget_exceeded_on_po, b.action_if_accumulated_monthly_budget_exceeded_on_po
from
`tabBudget` b, `tabBudget Account` ba
where
b.name=ba.parent and b.fiscal_year=%s
and ba.account=%s and b.docstatus=1
{condition}
""",
(params.company, params.posting_date, params.account),
""",
(args.fiscal_year, args.account),
as_dict=True,
) # nosec
if budget_records:
validate_budget_records(params, budget_records, expense_amount)
validate_budget_records(args, budget_records, expense_amount)
def validate_budget_records(params, budget_records, expense_amount):
def validate_budget_records(args, budget_records, expense_amount):
for budget in budget_records:
if flt(budget.budget_amount):
yearly_action, monthly_action = get_actions(params, budget)
params["for_material_request"] = budget.for_material_request
params["for_purchase_order"] = budget.for_purchase_order
params["from_fiscal_year"], params["to_fiscal_year"] = (
budget.from_fiscal_year,
budget.to_fiscal_year,
)
params["budget_start_date"], params["budget_end_date"] = (
budget.budget_start_date,
budget.budget_end_date,
)
yearly_action, monthly_action = get_actions(args, budget)
args["for_material_request"] = budget.for_material_request
args["for_purchase_order"] = budget.for_purchase_order
if yearly_action in ("Stop", "Warn"):
compare_expense_with_budget(
params,
args,
flt(budget.budget_amount),
_("Annual"),
yearly_action,
@@ -511,12 +241,14 @@ def validate_budget_records(params, budget_records, expense_amount):
)
if monthly_action in ["Stop", "Warn"]:
budget_amount = get_accumulated_monthly_budget(budget.name, params.posting_date)
budget_amount = get_accumulated_monthly_budget(
budget.monthly_distribution, args.posting_date, args.fiscal_year, budget.budget_amount
)
params["month_end_date"] = get_last_day(params.posting_date)
args["month_end_date"] = get_last_day(args.posting_date)
compare_expense_with_budget(
params,
args,
budget_amount,
_("Accumulated Monthly"),
monthly_action,
@@ -525,41 +257,40 @@ def validate_budget_records(params, budget_records, expense_amount):
)
def compare_expense_with_budget(params, budget_amount, action_for, action, budget_against, amount=0):
params.actual_expense, params.requested_amount, params.ordered_amount = get_actual_expense(params), 0, 0
def compare_expense_with_budget(args, budget_amount, action_for, action, budget_against, amount=0):
args.actual_expense, args.requested_amount, args.ordered_amount = get_actual_expense(args), 0, 0
if not amount:
params.requested_amount, params.ordered_amount = (
get_requested_amount(params),
get_ordered_amount(params),
)
args.requested_amount, args.ordered_amount = get_requested_amount(args), get_ordered_amount(args)
if params.get("doctype") == "Material Request" and params.for_material_request:
amount = params.requested_amount + params.ordered_amount
if args.get("doctype") == "Material Request" and args.for_material_request:
amount = args.requested_amount + args.ordered_amount
elif params.get("doctype") == "Purchase Order" and params.for_purchase_order:
amount = params.ordered_amount
elif args.get("doctype") == "Purchase Order" and args.for_purchase_order:
amount = args.ordered_amount
total_expense = params.actual_expense + amount
total_expense = args.actual_expense + amount
if total_expense > budget_amount:
if params.actual_expense > budget_amount:
diff = params.actual_expense - budget_amount
_msg = _("{0} Budget for Account {1} against {2} {3} is {4}. It is already exceeded by {5}.")
if args.actual_expense > budget_amount:
error_tense = _("is already")
diff = args.actual_expense - budget_amount
else:
error_tense = _("will be")
diff = total_expense - budget_amount
_msg = _("{0} Budget for Account {1} against {2} {3} is {4}. It will be exceeded by {5}.")
currency = frappe.get_cached_value("Company", params.company, "default_currency")
msg = _msg.format(
currency = frappe.get_cached_value("Company", args.company, "default_currency")
msg = _("{0} Budget for Account {1} against {2} {3} is {4}. It {5} exceed by {6}").format(
_(action_for),
frappe.bold(params.account),
frappe.unscrub(params.budget_against_field),
frappe.bold(args.account),
frappe.unscrub(args.budget_against_field),
frappe.bold(budget_against),
frappe.bold(fmt_money(budget_amount, currency=currency)),
error_tense,
frappe.bold(fmt_money(diff, currency=currency)),
)
msg += get_expense_breakup(params, currency, budget_against)
msg += get_expense_breakup(args, currency, budget_against)
if frappe.flags.exception_approver_role and frappe.flags.exception_approver_role in frappe.get_roles(
frappe.session.user
@@ -572,25 +303,14 @@ def compare_expense_with_budget(params, budget_amount, action_for, action, budge
frappe.msgprint(msg, indicator="orange", title=_("Budget Exceeded"))
def get_expense_breakup(params, currency, budget_against):
msg = "<hr> {} - <ul>".format(_("Total Expenses booked through"))
def get_expense_breakup(args, currency, budget_against):
msg = "<hr> {{ _('Total Expenses booked through') }} - <ul>"
common_filters = frappe._dict(
{
params.budget_against_field: budget_against,
"account": params.account,
"company": params.company,
}
)
from_date = frappe.get_cached_value("Fiscal Year", params.from_fiscal_year, "year_start_date")
to_date = frappe.get_cached_value("Fiscal Year", params.to_fiscal_year, "year_end_date")
gl_filters = common_filters.copy()
gl_filters.update(
{
"from_date": from_date,
"to_date": to_date,
"is_cancelled": 0,
args.budget_against_field: budget_against,
"account": args.account,
"company": args.company,
}
)
@@ -599,23 +319,18 @@ def get_expense_breakup(params, currency, budget_against):
+ frappe.utils.get_link_to_report(
"General Ledger",
label=_("Actual Expenses"),
filters=gl_filters,
filters=common_filters.copy().update(
{
"from_date": frappe.get_cached_value("Fiscal Year", args.fiscal_year, "year_start_date"),
"to_date": frappe.get_cached_value("Fiscal Year", args.fiscal_year, "year_end_date"),
"is_cancelled": 0,
}
),
)
+ " - "
+ frappe.bold(fmt_money(params.actual_expense, currency=currency))
+ frappe.bold(fmt_money(args.actual_expense, currency=currency))
+ "</li>"
)
mr_filters = common_filters.copy()
mr_filters.update(
{
"status": [["!=", "Stopped"]],
"docstatus": 1,
"material_request_type": "Purchase",
"schedule_date": [["between", [from_date, to_date]]],
"item_code": params.item_code,
"per_ordered": [["<", 100]],
}
)
msg += (
"<li>"
@@ -624,24 +339,22 @@ def get_expense_breakup(params, currency, budget_against):
label=_("Material Requests"),
report_type="Report Builder",
doctype="Material Request",
filters=mr_filters,
filters=common_filters.copy().update(
{
"status": [["!=", "Stopped"]],
"docstatus": 1,
"material_request_type": "Purchase",
"schedule_date": [["fiscal year", "2023-2024"]],
"item_code": args.item_code,
"per_ordered": [["<", 100]],
}
),
)
+ " - "
+ frappe.bold(fmt_money(params.requested_amount, currency=currency))
+ frappe.bold(fmt_money(args.requested_amount, currency=currency))
+ "</li>"
)
po_filters = common_filters.copy()
po_filters.update(
{
"status": [["!=", "Closed"]],
"docstatus": 1,
"transaction_date": [["between", [from_date, to_date]]],
"item_code": params.item_code,
"per_billed": [["<", 100]],
}
)
msg += (
"<li>"
+ frappe.utils.get_link_to_report(
@@ -649,34 +362,42 @@ def get_expense_breakup(params, currency, budget_against):
label=_("Unbilled Orders"),
report_type="Report Builder",
doctype="Purchase Order",
filters=po_filters,
filters=common_filters.copy().update(
{
"status": [["!=", "Closed"]],
"docstatus": 1,
"transaction_date": [["fiscal year", "2023-2024"]],
"item_code": args.item_code,
"per_billed": [["<", 100]],
}
),
)
+ " - "
+ frappe.bold(fmt_money(params.ordered_amount, currency=currency))
+ frappe.bold(fmt_money(args.ordered_amount, currency=currency))
+ "</li></ul>"
)
return msg
def get_actions(params, budget):
def get_actions(args, budget):
yearly_action = budget.action_if_annual_budget_exceeded
monthly_action = budget.action_if_accumulated_monthly_budget_exceeded
if params.get("doctype") == "Material Request" and budget.for_material_request:
if args.get("doctype") == "Material Request" and budget.for_material_request:
yearly_action = budget.action_if_annual_budget_exceeded_on_mr
monthly_action = budget.action_if_accumulated_monthly_budget_exceeded_on_mr
elif params.get("doctype") == "Purchase Order" and budget.for_purchase_order:
elif args.get("doctype") == "Purchase Order" and budget.for_purchase_order:
yearly_action = budget.action_if_annual_budget_exceeded_on_po
monthly_action = budget.action_if_accumulated_monthly_budget_exceeded_on_po
return yearly_action, monthly_action
def get_requested_amount(params):
item_code = params.get("item_code")
condition = get_other_condition(params, "Material Request")
def get_requested_amount(args):
item_code = args.get("item_code")
condition = get_other_condition(args, "Material Request")
data = frappe.db.sql(
""" select ifnull((sum(child.stock_qty - child.ordered_qty) * rate), 0) as amount
@@ -690,9 +411,9 @@ def get_requested_amount(params):
return data[0][0] if data else 0
def get_ordered_amount(params):
item_code = params.get("item_code")
condition = get_other_condition(params, "Purchase Order")
def get_ordered_amount(args):
item_code = args.get("item_code")
condition = get_other_condition(args, "Purchase Order")
data = frappe.db.sql(
f""" select ifnull(sum(child.amount - child.billed_amt), 0) as amount
@@ -706,102 +427,111 @@ def get_ordered_amount(params):
return data[0][0] if data else 0
def get_other_condition(params, for_doc):
condition = f"expense_account = '{params.expense_account}'"
budget_against_field = params.get("budget_against_field")
def get_other_condition(args, for_doc):
condition = "expense_account = '%s'" % (args.expense_account)
budget_against_field = args.get("budget_against_field")
if budget_against_field and params.get(budget_against_field):
condition += f" and child.{budget_against_field} = '{params.get(budget_against_field)}'"
if budget_against_field and args.get(budget_against_field):
condition += f" and child.{budget_against_field} = '{args.get(budget_against_field)}'"
date_field = "schedule_date" if for_doc == "Material Request" else "transaction_date"
if args.get("fiscal_year"):
date_field = "schedule_date" if for_doc == "Material Request" else "transaction_date"
start_date, end_date = frappe.get_cached_value(
"Fiscal Year", args.get("fiscal_year"), ["year_start_date", "year_end_date"]
)
start_date = frappe.get_cached_value("Fiscal Year", params.from_fiscal_year, "year_start_date")
end_date = frappe.get_cached_value("Fiscal Year", params.to_fiscal_year, "year_end_date")
condition += f" and parent.{date_field} between '{start_date}' and '{end_date}'"
condition += f""" and parent.{date_field}
between '{start_date}' and '{end_date}' """
return condition
def get_actual_expense(params):
if not params.budget_against_doctype:
params.budget_against_doctype = frappe.unscrub(params.budget_against_field)
def get_actual_expense(args):
if not args.budget_against_doctype:
args.budget_against_doctype = frappe.unscrub(args.budget_against_field)
budget_against_field = params.get("budget_against_field")
condition1 = " and gle.posting_date <= %(month_end_date)s" if params.get("month_end_date") else ""
budget_against_field = args.get("budget_against_field")
condition1 = " and gle.posting_date <= %(month_end_date)s" if args.get("month_end_date") else ""
date_condition = (
f"and gle.posting_date between '{params.budget_start_date}' and '{params.budget_end_date}'"
)
if params.is_tree:
if args.is_tree:
lft_rgt = frappe.db.get_value(
params.budget_against_doctype, params.get(budget_against_field), ["lft", "rgt"], as_dict=1
args.budget_against_doctype, args.get(budget_against_field), ["lft", "rgt"], as_dict=1
)
params.update(lft_rgt)
condition2 = f"""
and exists(
select name from `tab{params.budget_against_doctype}`
where lft >= %(lft)s and rgt <= %(rgt)s
and name = gle.{budget_against_field}
)
"""
args.update(lft_rgt)
condition2 = f"""and exists(select name from `tab{args.budget_against_doctype}`
where lft>=%(lft)s and rgt<=%(rgt)s
and name=gle.{budget_against_field})"""
else:
condition2 = f"""
and gle.{budget_against_field} = %({budget_against_field})s
"""
condition2 = f"""and exists(select name from `tab{args.budget_against_doctype}`
where name=gle.{budget_against_field} and
gle.{budget_against_field} = %({budget_against_field})s)"""
amount = flt(
frappe.db.sql(
f"""
select sum(gle.debit) - sum(gle.credit)
from `tabGL Entry` gle
where
is_cancelled = 0
and gle.account = %(account)s
{condition1}
{date_condition}
and gle.company = %(company)s
and gle.docstatus = 1
{condition2}
""",
params,
select sum(gle.debit) - sum(gle.credit)
from `tabGL Entry` gle
where
is_cancelled = 0
and gle.account=%(account)s
{condition1}
and gle.fiscal_year=%(fiscal_year)s
and gle.company=%(company)s
and gle.docstatus=1
{condition2}
""",
(args),
)[0][0]
) # nosec
return amount
def get_accumulated_monthly_budget(budget_name, posting_date):
posting_date = getdate(posting_date)
def get_accumulated_monthly_budget(monthly_distribution, posting_date, fiscal_year, annual_budget):
distribution = {}
if monthly_distribution:
mdp = frappe.qb.DocType("Monthly Distribution Percentage")
md = frappe.qb.DocType("Monthly Distribution")
bd = frappe.qb.DocType("Budget Distribution")
b = frappe.qb.DocType("Budget")
res = (
frappe.qb.from_(mdp)
.join(md)
.on(mdp.parent == md.name)
.select(mdp.month, mdp.percentage_allocation)
.where(md.fiscal_year == fiscal_year)
.where(md.name == monthly_distribution)
.run(as_dict=True)
)
result = (
frappe.qb.from_(bd)
.join(b)
.on(bd.parent == b.name)
.select(Sum(bd.amount).as_("accumulated_amount"))
.where(b.name == budget_name)
.where(bd.start_date <= posting_date)
.run(as_dict=True)
)
for d in res:
distribution.setdefault(d.month, d.percentage_allocation)
return flt(result[0]["accumulated_amount"]) if result else 0.0
dt = frappe.get_cached_value("Fiscal Year", fiscal_year, "year_start_date")
accumulated_percentage = 0.0
while dt <= getdate(posting_date):
if monthly_distribution and distribution:
accumulated_percentage += distribution.get(getdate(dt).strftime("%B"), 0)
else:
accumulated_percentage += 100.0 / 12
dt = add_months(dt, 1)
return annual_budget * accumulated_percentage / 100
def get_item_details(params):
def get_item_details(args):
cost_center, expense_account = None, None
if not params.get("company"):
if not args.get("company"):
return cost_center, expense_account
if params.item_code:
if args.item_code:
item_defaults = frappe.db.get_value(
"Item Default",
{"parent": params.item_code, "company": params.get("company")},
{"parent": args.item_code, "company": args.get("company")},
["buying_cost_center", "expense_account"],
)
if item_defaults:
@@ -809,7 +539,7 @@ def get_item_details(params):
if not (cost_center and expense_account):
for doctype in ["Item Group", "Company"]:
data = get_expense_cost_center(doctype, params)
data = get_expense_cost_center(doctype, args)
if not cost_center and data:
cost_center = data[0]
@@ -823,39 +553,14 @@ def get_item_details(params):
return cost_center, expense_account
def get_expense_cost_center(doctype, params):
def get_expense_cost_center(doctype, args):
if doctype == "Item Group":
return frappe.db.get_value(
"Item Default",
{"parent": params.get(frappe.scrub(doctype)), "company": params.get("company")},
{"parent": args.get(frappe.scrub(doctype)), "company": args.get("company")},
["buying_cost_center", "expense_account"],
)
else:
return frappe.db.get_value(
doctype, params.get(frappe.scrub(doctype)), ["cost_center", "default_expense_account"]
doctype, args.get(frappe.scrub(doctype)), ["cost_center", "default_expense_account"]
)
def get_fiscal_year_date_range(from_fiscal_year, to_fiscal_year):
from_year = frappe.get_cached_value(
"Fiscal Year", from_fiscal_year, ["year_start_date", "year_end_date"], as_dict=True
)
to_year = frappe.get_cached_value(
"Fiscal Year", to_fiscal_year, ["year_start_date", "year_end_date"], as_dict=True
)
return from_year.year_start_date, to_year.year_end_date
@frappe.whitelist()
def revise_budget(budget_name):
old_budget = frappe.get_doc("Budget", budget_name)
if old_budget.docstatus == 1:
old_budget.cancel()
new_budget = frappe.copy_doc(old_budget)
new_budget.docstatus = 0
new_budget.revision_of = old_budget.name
new_budget.insert()
return new_budget.name

View File

@@ -3,14 +3,12 @@
import unittest
import frappe
from frappe.client import submit
from frappe.utils import add_days, flt, get_first_day, get_last_day, getdate, now_datetime, nowdate
from frappe.utils import now_datetime, nowdate
from erpnext.accounts.doctype.budget.budget import (
BudgetError,
get_accumulated_monthly_budget,
get_actual_expense,
revise_budget,
)
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
from erpnext.accounts.utils import get_fiscal_year
@@ -27,15 +25,11 @@ class TestBudget(ERPNextTestSuite):
def setUp(self):
frappe.db.set_single_value("Accounts Settings", "use_legacy_budget_controller", False)
self.company = "_Test Company"
self.fiscal_year = frappe.db.get_value("Fiscal Year", {}, "name")
self.account = "_Test Account Cost for Goods Sold - _TC"
self.cost_center = "_Test Cost Center - _TC"
def test_monthly_budget_crossed_ignore(self):
set_total_expense_zero(nowdate(), "cost_center")
budget = make_budget(budget_against="Cost Center", do_not_save=False, submit_budget=True)
budget = make_budget(budget_against="Cost Center")
jv = make_journal_entry(
"_Test Account Cost for Goods Sold - _TC",
@@ -56,13 +50,12 @@ class TestBudget(ERPNextTestSuite):
def test_monthly_budget_crossed_stop1(self):
set_total_expense_zero(nowdate(), "cost_center")
budget = make_budget(budget_against="Cost Center", do_not_save=False, submit_budget=True)
budget = make_budget(budget_against="Cost Center")
frappe.db.set_value("Budget", budget.name, "action_if_accumulated_monthly_budget_exceeded", "Stop")
accumulated_limit = get_accumulated_monthly_budget(
budget.name,
nowdate(),
budget.monthly_distribution, nowdate(), budget.fiscal_year, budget.accounts[0].budget_amount
)
jv = make_journal_entry(
"_Test Account Cost for Goods Sold - _TC",
@@ -80,11 +73,13 @@ class TestBudget(ERPNextTestSuite):
def test_exception_approver_role(self):
set_total_expense_zero(nowdate(), "cost_center")
budget = make_budget(budget_against="Cost Center", do_not_save=False, submit_budget=True)
budget = make_budget(budget_against="Cost Center")
frappe.db.set_value("Budget", budget.name, "action_if_accumulated_monthly_budget_exceeded", "Stop")
accumulated_limit = get_accumulated_monthly_budget(budget.name, nowdate())
accumulated_limit = get_accumulated_monthly_budget(
budget.monthly_distribution, nowdate(), budget.fiscal_year, budget.accounts[0].budget_amount
)
jv = make_journal_entry(
"_Test Account Cost for Goods Sold - _TC",
"_Test Bank - _TC",
@@ -112,16 +107,16 @@ class TestBudget(ERPNextTestSuite):
applicable_on_purchase_order=1,
action_if_accumulated_monthly_budget_exceeded_on_mr="Stop",
budget_against="Cost Center",
do_not_save=False,
submit_budget=True,
)
fiscal_year = get_fiscal_year(nowdate())[0]
frappe.db.set_value("Budget", budget.name, "action_if_accumulated_monthly_budget_exceeded", "Stop")
frappe.db.set_value("Budget", budget.name, "fiscal_year", fiscal_year)
accumulated_limit = get_accumulated_monthly_budget(
budget.name,
nowdate(),
budget.monthly_distribution, nowdate(), budget.fiscal_year, budget.accounts[0].budget_amount
)
mr = frappe.get_doc(
{
"doctype": "Material Request",
@@ -156,15 +151,14 @@ class TestBudget(ERPNextTestSuite):
applicable_on_purchase_order=1,
action_if_accumulated_monthly_budget_exceeded_on_po="Stop",
budget_against="Cost Center",
do_not_save=False,
submit_budget=True,
)
fiscal_year = get_fiscal_year(nowdate())[0]
frappe.db.set_value("Budget", budget.name, "action_if_accumulated_monthly_budget_exceeded", "Stop")
frappe.db.set_value("Budget", budget.name, "fiscal_year", fiscal_year)
accumulated_limit = get_accumulated_monthly_budget(
budget.name,
nowdate(),
budget.monthly_distribution, nowdate(), budget.fiscal_year, budget.accounts[0].budget_amount
)
po = create_purchase_order(
transaction_date=nowdate(), qty=1, rate=accumulated_limit + 1, do_not_submit=True
@@ -181,14 +175,13 @@ class TestBudget(ERPNextTestSuite):
def test_monthly_budget_crossed_stop2(self):
set_total_expense_zero(nowdate(), "project")
budget = make_budget(budget_against="Project", do_not_save=False, submit_budget=True)
budget = make_budget(budget_against="Project")
frappe.db.set_value("Budget", budget.name, "action_if_accumulated_monthly_budget_exceeded", "Stop")
project = frappe.get_value("Project", {"project_name": "_Test Project"})
accumulated_limit = get_accumulated_monthly_budget(
budget.name,
nowdate(),
budget.monthly_distribution, nowdate(), budget.fiscal_year, budget.accounts[0].budget_amount
)
jv = make_journal_entry(
"_Test Account Cost for Goods Sold - _TC",
@@ -207,7 +200,7 @@ class TestBudget(ERPNextTestSuite):
def test_yearly_budget_crossed_stop1(self):
set_total_expense_zero(nowdate(), "cost_center")
budget = make_budget(budget_against="Cost Center", do_not_save=False, submit_budget=True)
budget = make_budget(budget_against="Cost Center")
jv = make_journal_entry(
"_Test Account Cost for Goods Sold - _TC",
@@ -224,7 +217,7 @@ class TestBudget(ERPNextTestSuite):
def test_yearly_budget_crossed_stop2(self):
set_total_expense_zero(nowdate(), "project")
budget = make_budget(budget_against="Project", do_not_save=False, submit_budget=True)
budget = make_budget(budget_against="Project")
project = frappe.get_value("Project", {"project_name": "_Test Project"})
@@ -244,7 +237,7 @@ class TestBudget(ERPNextTestSuite):
def test_monthly_budget_on_cancellation1(self):
set_total_expense_zero(nowdate(), "cost_center")
budget = make_budget(budget_against="Cost Center", do_not_save=False, submit_budget=True)
budget = make_budget(budget_against="Cost Center")
month = now_datetime().month
if month > 9:
month = 9
@@ -273,7 +266,7 @@ class TestBudget(ERPNextTestSuite):
def test_monthly_budget_on_cancellation2(self):
set_total_expense_zero(nowdate(), "project")
budget = make_budget(budget_against="Project", do_not_save=False, submit_budget=True)
budget = make_budget(budget_against="Project")
month = now_datetime().month
if month > 9:
month = 9
@@ -305,17 +298,11 @@ class TestBudget(ERPNextTestSuite):
set_total_expense_zero(nowdate(), "cost_center")
set_total_expense_zero(nowdate(), "cost_center", "_Test Cost Center 2 - _TC")
budget = make_budget(
budget_against="Cost Center",
cost_center="_Test Company - _TC",
do_not_save=False,
submit_budget=True,
)
budget = make_budget(budget_against="Cost Center", cost_center="_Test Company - _TC")
frappe.db.set_value("Budget", budget.name, "action_if_accumulated_monthly_budget_exceeded", "Stop")
accumulated_limit = get_accumulated_monthly_budget(
budget.name,
nowdate(),
budget.monthly_distribution, nowdate(), budget.fiscal_year, budget.accounts[0].budget_amount
)
jv = make_journal_entry(
"_Test Account Cost for Goods Sold - _TC",
@@ -344,14 +331,11 @@ class TestBudget(ERPNextTestSuite):
}
).insert(ignore_permissions=True)
budget = make_budget(
budget_against="Cost Center", cost_center=cost_center, do_not_save=False, submit_budget=True
)
budget = make_budget(budget_against="Cost Center", cost_center=cost_center)
frappe.db.set_value("Budget", budget.name, "action_if_accumulated_monthly_budget_exceeded", "Stop")
accumulated_limit = get_accumulated_monthly_budget(
budget.name,
nowdate(),
budget.monthly_distribution, nowdate(), budget.fiscal_year, budget.accounts[0].budget_amount
)
jv = make_journal_entry(
"_Test Account Cost for Goods Sold - _TC",
@@ -388,12 +372,7 @@ class TestBudget(ERPNextTestSuite):
{"Sub Budget Cost Center 1 - _TC": 60, "Sub Budget Cost Center 2 - _TC": 40},
)
make_budget(
budget_against="Cost Center",
cost_center="Main Budget Cost Center 1 - _TC",
do_not_save=False,
submit_budget=True,
)
make_budget(budget_against="Cost Center", cost_center="Main Budget Cost Center 1 - _TC")
jv = make_journal_entry(
"_Test Account Cost for Goods Sold - _TC",
@@ -408,14 +387,11 @@ class TestBudget(ERPNextTestSuite):
def test_action_for_cumulative_limit(self):
set_total_expense_zero(nowdate(), "cost_center")
budget = make_budget(
budget_against="Cost Center",
applicable_on_cumulative_expense=True,
do_not_save=False,
submit_budget=True,
)
budget = make_budget(budget_against="Cost Center", applicable_on_cumulative_expense=True)
accumulated_limit = get_accumulated_monthly_budget(budget.name, nowdate())
accumulated_limit = get_accumulated_monthly_budget(
budget.monthly_distribution, nowdate(), budget.fiscal_year, budget.accounts[0].budget_amount
)
jv = make_journal_entry(
"_Test Account Cost for Goods Sold - _TC",
@@ -446,165 +422,6 @@ class TestBudget(ERPNextTestSuite):
po.cancel()
jv.cancel()
def test_fiscal_year_validation(self):
frappe.get_doc(
{
"doctype": "Fiscal Year",
"year": "2100",
"year_start_date": "2100-04-01",
"year_end_date": "2101-03-31",
"companies": [{"company": "_Test Company"}],
}
).insert(ignore_permissions=True)
budget = make_budget(
budget_against="Cost Center",
from_fiscal_year="2100",
to_fiscal_year="2099",
do_not_save=True,
submit_budget=False,
)
with self.assertRaises(frappe.ValidationError):
budget.save()
def test_total_distribution_equals_budget(self):
budget = make_budget(
budget_against="Cost Center",
applicable_on_cumulative_expense=True,
distribute_equally=0,
budget_amount=12000,
do_not_save=False,
submit_budget=False,
)
for row in budget.budget_distribution:
row.amount = 2000
with self.assertRaises(frappe.ValidationError):
budget.save()
def test_evenly_distribute_budget(self):
budget = make_budget(
budget_against="Cost Center", budget_amount=120000, do_not_save=False, submit_budget=True
)
total = sum([d.amount for d in budget.budget_distribution])
self.assertEqual(flt(total), 120000)
self.assertTrue(all(d.amount == 10000 for d in budget.budget_distribution))
def test_create_revised_budget(self):
budget = make_budget(
budget_against="Cost Center", budget_amount=120000, do_not_save=False, submit_budget=True
)
revised_name = revise_budget(budget.name)
revised_budget = frappe.get_doc("Budget", revised_name)
self.assertNotEqual(budget.name, revised_budget.name)
self.assertEqual(revised_budget.budget_against, budget.budget_against)
self.assertEqual(revised_budget.budget_amount, budget.budget_amount)
old_budget = frappe.get_doc("Budget", budget.name)
self.assertEqual(old_budget.docstatus, 2)
def test_revision_preserves_distribution(self):
set_total_expense_zero(nowdate(), "cost_center", "_Test Cost Center - _TC")
budget = make_budget(
budget_against="Cost Center", budget_amount=120000, do_not_save=False, submit_budget=True
)
revised_name = revise_budget(budget.name)
revised_budget = frappe.get_doc("Budget", revised_name)
self.assertGreater(len(revised_budget.budget_distribution), 0)
total = sum(row.amount for row in revised_budget.budget_distribution)
self.assertEqual(total, revised_budget.budget_amount)
def test_manual_budget_amount_total(self):
budget = make_budget(
budget_against="Cost Center",
distribute_equally=0,
budget_amount=30000,
budget_start_date="2025-04-01",
budget_end_date="2025-06-30",
do_not_save=False,
submit_budget=False,
)
budget.budget_distribution = []
for row in [
{"start_date": "2025-04-01", "end_date": "2025-04-30", "amount": 10000, "percent": 33.33},
{"start_date": "2025-05-01", "end_date": "2025-05-31", "amount": 15000, "percent": 50.00},
{"start_date": "2025-06-01", "end_date": "2025-06-30", "amount": 5000, "percent": 16.67},
]:
budget.append("budget_distribution", row)
budget.save()
total_child_amount = sum(row.amount for row in budget.budget_distribution)
self.assertEqual(total_child_amount, budget.budget_amount)
def test_fiscal_year_company_mismatch(self):
budget = make_budget(budget_against="Cost Center", do_not_save=True, submit_budget=False)
fy = frappe.get_doc(
{
"doctype": "Fiscal Year",
"year": "2099",
"year_start_date": "2099-04-01",
"year_end_date": "2100-03-31",
"companies": [{"company": "_Test Company 2"}],
}
).insert(ignore_permissions=True)
budget.from_fiscal_year = fy.name
budget.to_fiscal_year = fy.name
budget.company = "_Test Company"
with self.assertRaises(frappe.ValidationError):
budget.save()
def test_manual_distribution_total_equals_budget_amount(self):
budget = make_budget(
budget_against="Cost Center",
cost_center="_Test Cost Center - _TC",
distribute_equally=0,
budget_amount=12000,
do_not_save=False,
submit_budget=False,
)
for d in budget.budget_distribution:
d.amount = 2000
with self.assertRaises(frappe.ValidationError):
budget.save()
def test_duplicate_budget_validation(self):
budget = make_budget(
budget_against="Cost Center",
distribute_equally=1,
budget_amount=15000,
do_not_save=False,
submit_budget=True,
)
new_budget = frappe.new_doc("Budget")
new_budget.company = "_Test Company"
new_budget.from_fiscal_year = budget.from_fiscal_year
new_budget.to_fiscal_year = new_budget.from_fiscal_year
new_budget.budget_against = "Cost Center"
new_budget.cost_center = "_Test Cost Center - _TC"
new_budget.account = "_Test Account Cost for Goods Sold - _TC"
new_budget.budget_amount = 10000
with self.assertRaises(frappe.ValidationError):
new_budget.insert()
def set_total_expense_zero(posting_date, budget_against_field=None, budget_against_CC=None):
if budget_against_field == "project":
@@ -613,32 +430,21 @@ def set_total_expense_zero(posting_date, budget_against_field=None, budget_again
budget_against = budget_against_CC or "_Test Cost Center - _TC"
fiscal_year = get_fiscal_year(nowdate())[0]
fiscal_year_start_date, fiscal_year_end_date = get_fiscal_year(nowdate())[1:3]
args = frappe._dict(
{
"account": "_Test Account Cost for Goods Sold - _TC",
"cost_center": "_Test Cost Center - _TC",
"month_end_date": posting_date,
"monthly_end_date": posting_date,
"company": "_Test Company",
"from_fiscal_year": fiscal_year,
"to_fiscal_year": fiscal_year,
"fiscal_year": fiscal_year,
"budget_against_field": budget_against_field,
"budget_start_date": fiscal_year_start_date,
"budget_end_date": fiscal_year_end_date,
}
)
if not args.get(budget_against_field):
args[budget_against_field] = budget_against
args.budget_against_doctype = frappe.unscrub(budget_against_field)
if frappe.get_cached_value("DocType", args.budget_against_doctype, "is_tree"):
args.is_tree = True
else:
args.is_tree = False
existing_expense = get_actual_expense(args)
if existing_expense:
@@ -668,33 +474,18 @@ def make_budget(**args):
budget_against = args.budget_against
cost_center = args.cost_center
fiscal_year = get_fiscal_year(nowdate())[0]
if budget_against == "Project":
project = frappe.get_value("Project", {"project_name": "_Test Project"})
budget_list = frappe.get_all(
"Budget",
filters={
"project": project,
"account": "_Test Account Cost for Goods Sold - _TC",
},
pluck="name",
)
project_name = "{}%".format("_Test Project/" + fiscal_year)
budget_list = frappe.get_all("Budget", fields=["name"], filters={"name": ("like", project_name)})
else:
budget_list = frappe.get_all(
"Budget",
filters={
"cost_center": cost_center or "_Test Cost Center - _TC",
"account": "_Test Account Cost for Goods Sold - _TC",
},
pluck="name",
)
for name in budget_list:
doc = frappe.get_doc("Budget", name)
if doc.docstatus == 1:
doc.cancel()
frappe.delete_doc("Budget", name, force=True, ignore_missing=True)
cost_center_name = "{}%".format(cost_center or "_Test Cost Center - _TC/" + fiscal_year)
budget_list = frappe.get_all("Budget", fields=["name"], filters={"name": ("like", cost_center_name)})
for d in budget_list:
frappe.db.sql("delete from `tabBudget` where name = %(name)s", d)
frappe.db.sql("delete from `tabBudget Account` where parent = %(name)s", d)
budget = frappe.new_doc("Budget")
@@ -703,18 +494,18 @@ def make_budget(**args):
else:
budget.cost_center = cost_center or "_Test Cost Center - _TC"
budget.from_fiscal_year = args.from_fiscal_year or fiscal_year
budget.to_fiscal_year = args.to_fiscal_year or fiscal_year
monthly_distribution = frappe.get_doc("Monthly Distribution", "_Test Distribution")
monthly_distribution.fiscal_year = fiscal_year
monthly_distribution.save()
budget.fiscal_year = fiscal_year
budget.monthly_distribution = "_Test Distribution"
budget.company = "_Test Company"
budget.account = "_Test Account Cost for Goods Sold - _TC"
budget.budget_amount = args.budget_amount or 200000
budget.applicable_on_booking_actual_expenses = 1
budget.action_if_annual_budget_exceeded = "Stop"
budget.action_if_accumulated_monthly_budget_exceeded = "Ignore"
budget.budget_against = budget_against
budget.distribution_frequency = "Monthly"
budget.distribute_equally = args.get("distribute_equally", 1)
budget.append("accounts", {"account": "_Test Account Cost for Goods Sold - _TC", "budget_amount": 200000})
if args.applicable_on_material_request:
budget.applicable_on_material_request = 1
@@ -739,13 +530,7 @@ def make_budget(**args):
args.action_if_accumulated_monthly_exceeded_on_cumulative_expense or "Warn"
)
if not args.do_not_save:
try:
budget.insert(ignore_if_duplicate=True)
except frappe.DuplicateEntryError:
pass
if args.submit_budget:
budget.submit()
budget.insert()
budget.submit()
return budget

View File

@@ -1,58 +0,0 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2025-10-12 23:31:03.841996",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"start_date",
"end_date",
"amount",
"percent"
],
"fields": [
{
"fieldname": "start_date",
"fieldtype": "Date",
"in_list_view": 1,
"label": "Start Date",
"read_only": 1,
"search_index": 1
},
{
"fieldname": "end_date",
"fieldtype": "Date",
"in_list_view": 1,
"label": "End Date",
"read_only": 1
},
{
"fieldname": "amount",
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Amount"
},
{
"fieldname": "percent",
"fieldtype": "Percent",
"in_list_view": 1,
"label": "Percent"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-11-03 13:18:28.398198",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Budget Distribution",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"rows_threshold_for_grid_search": 20,
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View File

@@ -19,7 +19,7 @@ frappe.ui.form.on("Currency Exchange Settings", {
to: "{to_currency}",
};
add_param(frm, r.message, params, result);
} else if (["frankfurter.app", "frankfurter.dev"].includes(frm.doc.service_provider)) {
} else if (frm.doc.service_provider == "frankfurter.app") {
let result = ["rates", "{to_currency}"];
let params = {
base: "{from_currency}",

View File

@@ -78,7 +78,7 @@
"fieldname": "service_provider",
"fieldtype": "Select",
"label": "Service Provider",
"options": "frankfurter.dev\nexchangerate.host\nCustom",
"options": "frankfurter.app\nexchangerate.host\nCustom",
"reqd": 1
},
{
@@ -101,11 +101,10 @@
"label": "Use HTTP Protocol"
}
],
"hide_toolbar": 1,
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-01-02 18:19:02.873815",
"modified": "2024-03-27 13:06:47.653110",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Currency Exchange Settings",
@@ -142,9 +141,8 @@
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -29,7 +29,7 @@ class CurrencyExchangeSettings(Document):
disabled: DF.Check
req_params: DF.Table[CurrencyExchangeSettingsDetails]
result_key: DF.Table[CurrencyExchangeSettingsResult]
service_provider: DF.Literal["frankfurter.dev", "exchangerate.host", "Custom"]
service_provider: DF.Literal["frankfurter.app", "exchangerate.host", "Custom"]
url: DF.Data | None
use_http: DF.Check
# end: auto-generated types
@@ -60,7 +60,7 @@ class CurrencyExchangeSettings(Document):
self.append("req_params", {"key": "date", "value": "{transaction_date}"})
self.append("req_params", {"key": "from", "value": "{from_currency}"})
self.append("req_params", {"key": "to", "value": "{to_currency}"})
elif self.service_provider in ("frankfurter.dev", "frankfurter.app"):
elif self.service_provider == "frankfurter.app":
self.set("result_key", [])
self.set("req_params", [])
@@ -105,13 +105,11 @@ class CurrencyExchangeSettings(Document):
@frappe.whitelist()
def get_api_endpoint(service_provider: str | None = None, use_http: bool = False):
if service_provider and service_provider in ["exchangerate.host", "frankfurter.dev", "frankfurter.app"]:
if service_provider and service_provider in ["exchangerate.host", "frankfurter.app"]:
if service_provider == "exchangerate.host":
api = "api.exchangerate.host/convert"
elif service_provider == "frankfurter.app":
api = "api.frankfurter.app/{transaction_date}"
elif service_provider == "frankfurter.dev":
api = "api.frankfurter.dev/v1/{transaction_date}"
protocol = "https://"
if use_http:

View File

@@ -252,7 +252,7 @@ class ExchangeRateRevaluation(Document):
company_currency = erpnext.get_company_currency(company)
precision = get_field_precision(
frappe.get_meta("Exchange Rate Revaluation Account").get_field("new_balance_in_base_currency"),
currency=company_currency,
company_currency,
)
if account_details:

View File

@@ -3,8 +3,6 @@
import frappe
from frappe.query_builder import functions
from frappe.query_builder.utils import DocType
from frappe.tests import IntegrationTestCase
from frappe.utils import add_days, flt, today
@@ -83,11 +81,10 @@ class TestExchangeRateRevaluation(AccountsTestMixin, IntegrationTestCase):
self.assertEqual(je.total_debit, 8500.0)
self.assertEqual(je.total_credit, 8500.0)
gl = DocType("GL Entry")
acc_balance = frappe.db.get_all(
"GL Entry",
filters={"account": self.debtors_usd, "is_cancelled": 0},
fields=[(functions.Sum(gl.debit) - functions.Sum(gl.credit)).as_("balance")],
fields=["sum(debit)-sum(credit) as balance"],
)[0]
self.assertEqual(acc_balance.balance, 8500.0)
@@ -149,15 +146,12 @@ class TestExchangeRateRevaluation(AccountsTestMixin, IntegrationTestCase):
self.assertEqual(je.total_debit, 500.0)
self.assertEqual(je.total_credit, 500.0)
gl = DocType("GL Entry")
acc_balance = frappe.db.get_all(
"GL Entry",
filters={"account": self.debtors_usd, "is_cancelled": 0},
fields=[
(functions.Sum(gl.debit) - functions.Sum(gl.credit)).as_("balance"),
(
functions.Sum(gl.debit_in_account_currency) - functions.Sum(gl.credit_in_account_currency)
).as_("balance_in_account_currency"),
"sum(debit)-sum(credit) as balance",
"sum(debit_in_account_currency)-sum(credit_in_account_currency) as balance_in_account_currency",
],
)[0]
# account shouldn't have balance in base and account currency
@@ -199,15 +193,12 @@ class TestExchangeRateRevaluation(AccountsTestMixin, IntegrationTestCase):
pe.references = []
pe.save().submit()
gl = DocType("GL Entry")
acc_balance = frappe.db.get_all(
"GL Entry",
filters={"account": self.debtors_usd, "is_cancelled": 0},
fields=[
(functions.Sum(gl.debit) - functions.Sum(gl.credit)).as_("balance"),
(
functions.Sum(gl.debit_in_account_currency) - functions.Sum(gl.credit_in_account_currency)
).as_("balance_in_account_currency"),
"sum(debit)-sum(credit) as balance",
"sum(debit_in_account_currency)-sum(credit_in_account_currency) as balance_in_account_currency",
],
)[0]
# account should have balance only in account currency
@@ -244,15 +235,12 @@ class TestExchangeRateRevaluation(AccountsTestMixin, IntegrationTestCase):
self.assertEqual(flt(je.total_debit, precision), 0.0)
self.assertEqual(flt(je.total_credit, precision), 0.0)
gl = DocType("GL Entry")
acc_balance = frappe.db.get_all(
"GL Entry",
filters={"account": self.debtors_usd, "is_cancelled": 0},
fields=[
(functions.Sum(gl.debit) - functions.Sum(gl.credit)).as_("balance"),
(
functions.Sum(gl.debit_in_account_currency) - functions.Sum(gl.credit_in_account_currency)
).as_("balance_in_account_currency"),
"sum(debit)-sum(credit) as balance",
"sum(debit_in_account_currency)-sum(credit_in_account_currency) as balance_in_account_currency",
],
)[0]
# account shouldn't have balance in base and account currency post revaluation

View File

@@ -1,187 +0,0 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2025-09-06 09:39:46.503678",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"reference_code",
"display_name",
"indentation_level",
"data_source",
"balance_type",
"column_break_hxqu",
"fieldtype",
"color",
"bold_text",
"italic_text",
"hidden_calculation",
"hide_when_empty",
"reverse_sign",
"include_in_charts",
"section_break_ornw",
"column_break_asfe",
"advanced_filtering",
"filters_editor",
"calculation_formula",
"section_break_pvro",
"formula_description"
],
"fields": [
{
"columns": 1,
"description": "Code to reference this line in formulas (e.g., REV100, EXP200, ASSET100)",
"fieldname": "reference_code",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Line Reference"
},
{
"description": "Text displayed on the financial statement (e.g., 'Total Revenue', 'Cash and Cash Equivalents')",
"fieldname": "display_name",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Display Name"
},
{
"columns": 1,
"description": "Indentation level: 0 = Main heading, 1 = Sub-category, 2 = Individual accounts, etc.",
"fieldname": "indentation_level",
"fieldtype": "Int",
"in_list_view": 1,
"label": "Indent Level"
},
{
"description": "How this line gets its data",
"fieldname": "data_source",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Data Source",
"options": "\nAccount Data\nCalculated Amount\nCustom API\nBlank Line\nColumn Break\nSection Break"
},
{
"depends_on": "eval:doc.data_source == 'Account Data'",
"description": "Opening Balance = Start of period, Closing Balance = End of period, Period Movement = Net change during period",
"fieldname": "balance_type",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Balance Type",
"mandatory_depends_on": "eval:doc.data_source == 'Account Data'",
"options": "\nOpening Balance\nClosing Balance\nPeriod Movement (Debits - Credits)"
},
{
"fieldname": "column_break_hxqu",
"fieldtype": "Column Break"
},
{
"default": "0",
"description": "Bold text for emphasis (totals, major headings)",
"fieldname": "bold_text",
"fieldtype": "Check",
"label": "Bold Text"
},
{
"default": "0",
"description": "Italic text for subtotals or notes",
"fieldname": "italic_text",
"fieldtype": "Check",
"label": "Italic Text"
},
{
"default": "0",
"description": "Calculate but don't show on final report",
"fieldname": "hidden_calculation",
"fieldtype": "Check",
"label": "Hidden Line (Internal Use Only)"
},
{
"default": "0",
"description": "Hide this line if amount is zero",
"fieldname": "hide_when_empty",
"fieldtype": "Check",
"label": "Hide If Zero"
},
{
"columns": 1,
"default": "0",
"description": "Show negative values as positive (for expenses in P&L)",
"fieldname": "reverse_sign",
"fieldtype": "Check",
"in_list_view": 1,
"label": "Reverse Sign"
},
{
"fieldname": "section_break_ornw",
"fieldtype": "Section Break"
},
{
"depends_on": "eval: (doc.data_source === \"Account Data\" && doc.advanced_filtering) || [\"Calculated Amount\", \"Custom API\"].includes(doc.data_source);\n",
"fieldname": "calculation_formula",
"fieldtype": "Code",
"label": "Formula or Account Filter",
"mandatory_depends_on": "eval:doc.data_source != 'Blank Line' && doc.data_source != 'Column Break' && doc.data_source != 'Section Break'"
},
{
"fieldname": "formula_description",
"fieldtype": "HTML"
},
{
"default": "0",
"description": "If enabled, this row's values will be displayed on financial charts",
"fieldname": "include_in_charts",
"fieldtype": "Check",
"label": "Include in Charts"
},
{
"description": "Color to highlight values (e.g., red for exceptions)",
"fieldname": "color",
"fieldtype": "Color",
"label": "Color"
},
{
"description": "How to format and present values in the financial report (only if different from column fieldtype)",
"fieldname": "fieldtype",
"fieldtype": "Select",
"label": "Value Type",
"options": "\nCurrency\nFloat\nInt\nPercent"
},
{
"depends_on": "eval: doc.data_source === \"Account Data\" && !doc.advanced_filtering",
"fieldname": "filters_editor",
"fieldtype": "HTML"
},
{
"depends_on": "eval: ![\"Blank Line\", \"Column Break\", \"Section Break\"].includes(doc.data_source);",
"fieldname": "column_break_asfe",
"fieldtype": "Column Break"
},
{
"default": "0",
"depends_on": "eval: doc.data_source === \"Account Data\"",
"description": "Use <strong>Python</strong> filters to get Accounts",
"fieldname": "advanced_filtering",
"fieldtype": "Check",
"label": "Advanced Filtering",
"print_hide": 1
},
{
"fieldname": "section_break_pvro",
"fieldtype": "Section Break"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-10-14 09:23:27.208072",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Financial Report Row",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View File

@@ -1,47 +0,0 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class FinancialReportRow(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frappe.types import DF
advanced_filtering: DF.Check
balance_type: DF.Literal[
"", "Opening Balance", "Closing Balance", "Period Movement (Debits - Credits)"
]
bold_text: DF.Check
calculation_formula: DF.Code | None
color: DF.Color | None
data_source: DF.Literal[
"",
"Account Data",
"Calculated Amount",
"Custom API",
"Blank Line",
"Column Break",
"Section Break",
]
display_name: DF.Data | None
fieldtype: DF.Literal["", "Currency", "Float", "Int", "Percent"]
hidden_calculation: DF.Check
hide_when_empty: DF.Check
include_in_charts: DF.Check
indentation_level: DF.Int
italic_text: DF.Check
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
reference_code: DF.Data | None
reverse_sign: DF.Check
# end: auto-generated types
pass

View File

@@ -1,433 +0,0 @@
// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
frappe.ui.form.on("Financial Report Template", {
refresh(frm) {
// add custom button to view missed accounts
frm.add_custom_button(__("View Account Coverage"), function () {
let selected_rows = frm.get_field("rows").grid.get_selected_children();
const has_selection = selected_rows.length > 0;
if (selected_rows.length === 0) selected_rows = frm.doc.rows;
show_accounts_tree(selected_rows, has_selection);
});
// add custom button to open the financial report
frm.add_custom_button(__("View Report"), function () {
frappe.set_route("query-report", frm.doc.report_type, {
report_template: frm.doc.name,
});
});
},
validate(frm) {
if (!frm.doc.rows || frm.doc.rows.length === 0) {
frappe.msgprint(__("At least one row is required for a financial report template"));
}
},
});
frappe.ui.form.on("Financial Report Row", {
data_source(frm, cdt, cdn) {
const row = locals[cdt][cdn];
update_formula_label(frm, row.data_source);
update_formula_description(frm, row.data_source);
if (row.data_source !== "Account Data") {
frappe.model.set_value(cdt, cdn, "balance_type", "");
}
if (["Blank Line", "Column Break", "Section Break"].includes(row.data_source)) {
frappe.model.set_value(cdt, cdn, "calculation_formula", "");
}
set_up_filters_editor(frm, cdt, cdn);
},
form_render(frm, cdt, cdn) {
const row = locals[cdt][cdn];
update_formula_label(frm, row.data_source);
update_advanced_formula_property(frm, cdt, cdn);
set_up_filters_editor(frm, cdt, cdn);
update_formula_description(frm, row.data_source);
},
calculation_formula(frm, cdt, cdn) {
update_advanced_formula_property(frm, cdt, cdn);
},
advanced_filtering(frm, cdt, cdn) {
set_up_filters_editor(frm, cdt, cdn);
},
});
// FILTERS EDITOR
function set_up_filters_editor(frm, cdt, cdn) {
const row = locals[cdt][cdn];
if (row.data_source !== "Account Data" || row.advanced_filtering) return;
const grid_row = frm.fields_dict["rows"].grid.get_row(cdn);
const wrapper = grid_row.get_field("filters_editor").$wrapper;
wrapper.empty();
const ACCOUNT = "Account";
const FIELD_IDX = 1;
const OPERATOR_IDX = 2;
const VALUE_IDX = 3;
// Parse saved filters
let saved_filters = [];
if (row.calculation_formula) {
try {
const parsed = JSON.parse(row.calculation_formula);
if (Array.isArray(parsed)) saved_filters = [parsed];
else if (parsed.and) saved_filters = parsed.and;
} catch (e) {
frappe.show_alert({
message: __("Invalid filter formula. Please check the syntax."),
indicator: "red",
});
}
}
if (saved_filters.length)
// Ensure every filter starts with "Account"
saved_filters = saved_filters.map((f) => [ACCOUNT, ...f]);
frappe.model.with_doctype(ACCOUNT, () => {
const filter_group = new frappe.ui.FilterGroup({
parent: wrapper,
doctype: ACCOUNT,
on_change: () => {
// only need [[field, operator, value]]
const filters = filter_group
.get_filters()
.map((f) => [f[FIELD_IDX], f[OPERATOR_IDX], f[VALUE_IDX]]);
const current = filters.length > 1 ? { and: filters } : filters[0];
frappe.model.set_value(cdt, cdn, "calculation_formula", JSON.stringify(current));
},
});
filter_group.add_filters_to_filter_group(saved_filters);
});
}
function update_advanced_formula_property(frm, cdt, cdn) {
const row = locals[cdt][cdn];
const is_advanced = is_advanced_formula(row);
frm.set_df_property("rows", "read_only", is_advanced, frm.doc.name, "advanced_filtering", cdn);
if (is_advanced && !row.advanced_filtering) {
row.advanced_filtering = 1;
frm.refresh_field("rows");
}
}
function is_advanced_formula(row) {
if (!row || row.data_source !== "Account Data") return false;
let parsed = null;
if (row.calculation_formula) {
try {
parsed = JSON.parse(row.calculation_formula);
} catch (e) {
console.warn("Invalid JSON in calculation_formula:", e);
return false;
}
}
if (Array.isArray(parsed)) return false;
if (parsed?.or) return true;
if (parsed?.and) return parsed.and.some((cond) => !Array.isArray(cond));
return false;
}
// ACCOUNTS TREE VIEW
function show_accounts_tree(template_rows, has_selection) {
// filtered rows
const account_rows = template_rows.filter((row) => row.data_source === "Account Data");
if (account_rows.length === 0) {
frappe.show_alert(__("No <strong>Account Data</strong> row found"));
return;
}
const dialog = new frappe.ui.Dialog({
title: __("Accounts Missing from Report"),
fields: [
{
fieldname: "company",
fieldtype: "Link",
options: "Company",
label: "Company",
reqd: 1,
default: frappe.defaults.get_user_default("Company"),
onchange: () => {
const company_field = dialog.get_field("company");
if (!company_field.value || company_field.value === company_field.last_value) return;
refresh_tree_view(dialog, account_rows);
},
},
{
fieldname: "view_type",
fieldtype: "Select",
options: ["Missing Accounts", "Filtered Accounts"],
label: "View",
default: has_selection ? "Filtered Accounts" : "Missing Accounts",
reqd: 1,
onchange: () => {
dialog.set_title(
dialog.get_value("view_type") === "Missing Accounts"
? __("Accounts Missing from Report")
: __("Accounts Included in Report")
);
refresh_tree_view(dialog, account_rows);
},
},
{
fieldname: "tip",
fieldtype: "HTML",
label: "Tip",
options: `
<div class="alert alert-success" role="alert">
Tip: Select report lines to view their accounts
</div>
`,
depends_on: has_selection ? "eval: false" : "eval: true",
},
{
fieldname: "tree_area",
fieldtype: "HTML",
label: "Chart of Accounts",
read_only: 1,
depends_on: "eval: doc.company",
},
],
primary_action_label: __("Done"),
primary_action() {
dialog.hide();
},
});
dialog.show();
refresh_tree_view(dialog, account_rows);
}
async function refresh_tree_view(dialog, account_rows) {
const missed = dialog.get_value("view_type") === "Missing Accounts";
const company = dialog.get_value("company");
const wrapper = dialog.get_field("tree_area").$wrapper;
wrapper.empty();
// get filtered accounts
const { message: filtered_accounts } = await frappe.call({
method: "erpnext.accounts.doctype.financial_report_template.financial_report_engine.get_filtered_accounts",
args: { company: company, account_rows: account_rows },
});
// render tree
const tree = new FilteredTree({
parent: wrapper,
label: company,
root_value: company,
method: "erpnext.accounts.doctype.financial_report_template.financial_report_engine.get_children_accounts",
args: { doctype: "Account", company: company, filtered_accounts: filtered_accounts, missed: missed },
toolbar: [],
});
tree.load_children(tree.root_node, true);
}
class FilteredTree extends frappe.ui.Tree {
render_children_of_all_nodes(data_list) {
data_list = this.get_filtered_data_list(data_list);
super.render_children_of_all_nodes(data_list);
}
get_filtered_data_list(data_list) {
let removed_nodes = new Set();
// Filter nodes with no data
data_list = data_list.filter((d) => {
if (d.data.length === 0) {
removed_nodes.add(d.parent);
return false;
}
return true;
});
// Remove references to removed nodes and iteratively remove empty parents
while (removed_nodes.size > 0) {
const current_removed = [...removed_nodes];
removed_nodes.clear();
data_list = data_list.filter((d) => {
d.data = d.data.filter((a) => !current_removed.includes(a.value));
if (d.data.length === 0) {
removed_nodes.add(d.parent);
return false;
}
return true;
});
}
return data_list;
}
}
function update_formula_label(frm, data_source) {
const grid = frm.fields_dict.rows.grid;
const field = grid.fields_map.calculation_formula;
if (!field) return;
const labels = {
"Account Data": "Account Filter",
"Custom API": "API Method Path",
};
grid.update_docfield_property(
"calculation_formula",
"label",
labels[data_source] || "Calculation Formula"
);
}
// FORMULA DESCRIPTION
function update_formula_description(frm, data_source) {
if (!data_source) return;
let grid = frm.fields_dict.rows.grid;
let field = grid.fields_map.formula_description;
if (!field) return;
// Common CSS styles and elements
const container_style = `style="padding: var(--padding-md); border: 1px solid var(--border-color); border-radius: var(--border-radius); margin-top: var(--margin-sm);"`;
const title_style = `style="margin-top: 0; color: var(--text-color);"`;
const subtitle_style = `style="color: var(--text-color); margin-bottom: var(--margin-xs);"`;
const text_style = `style="margin-bottom: var(--margin-sm); color: var(--text-muted);"`;
const list_style = `style="margin-bottom: var(--margin-sm); color: var(--text-muted); font-size: 0.9em;"`;
const note_style = `style="margin-bottom: 0; color: var(--text-muted); font-size: 0.9em;"`;
const tip_style = `style="margin-bottom: 0; color: var(--text-color); font-size: 0.85em;"`;
let description_html = "";
if (data_source === "Account Data") {
description_html = `
<div ${container_style}>
<h5 ${title_style}>Account Filter Guide</h5>
<p ${text_style}>Specify which accounts to include in this line.</p>
<h6 ${subtitle_style}>Basic Examples:</h6>
<ul ${list_style}>
<li><code>["account_type", "=", "Cash"]</code> - All Cash accounts</li>
<li><code>["root_type", "in", ["Asset", "Liability"]]</code> - All Asset and Liability accounts</li>
<li><code>["account_category", "like", "Revenue"]</code> - Revenue accounts</li>
</ul>
<h6 ${subtitle_style}>Multiple Conditions (AND/OR):</h6>
<ul ${list_style}>
<li><code>{"and": [["root_type", "=", "Asset"], ["account_type", "=", "Cash"]]}</code></li>
<li><code>{"or": [["account_category", "like", "Revenue"], ["account_category", "like", "Income"]]}</code></li>
</ul>
<p ${note_style}><strong>Available operators:</strong> <code>=, !=, in, not in, like, not like, is</code></p>
<p ${tip_style}><strong>Multi-Company Tip:</strong> Use fields like <code>account_type</code>, <code>root_type</code>, and <code>account_category</code> for templates that work across multiple companies.</p>
</div>`;
} else if (data_source === "Calculated Amount") {
description_html = `
<div ${container_style}>
<h5 ${title_style}>Formula Guide</h5>
<p ${text_style}>Create calculations using reference codes from other lines.</p>
<h6 ${subtitle_style}>Basic Examples:</h6>
<ul ${list_style}>
<li><code>REV100 + REV200</code> - Add two revenue lines</li>
<li><code>ASSETS - LIABILITIES</code> - Calculate equity</li>
<li><code>REVENUE * 0.1</code> - 10% of revenue</li>
</ul>
<h6 ${subtitle_style}>Common Functions:</h6>
<ul ${list_style}>
<li><code>abs(value)</code> - Remove negative sign</li>
<li><code>round(value)</code> - Round to whole number</li>
<li><code>max(val1, val2)</code> - Larger of two values</li>
<li><code>min(val1, val2)</code> - Smaller of two values</li>
</ul>
<p ${note_style}><strong>Required:</strong> Use "Reference Code" from other rows in your formulas.</p>
</div>`;
} else if (data_source === "Custom API") {
description_html = `
<div ${container_style}>
<h5 ${title_style}>Custom API Setup</h5>
<p ${text_style}>Path to your custom method that returns financial data.</p>
<h6 ${subtitle_style}>Format:</h6>
<ul ${list_style}>
<li><code>erpnext.custom.financial_apis.get_custom_revenue</code></li>
<li><code>my_app.financial_reports.get_kpi_data</code></li>
</ul>
<h6 ${subtitle_style}>Return Format:</h6>
<p ${text_style}>Numbers for each period: <code>[1000.0, 1200.0, 1150.0]</code></p>
</div>`;
} else if (data_source === "Blank Line") {
description_html = `
<div ${container_style}>
<h5 ${title_style}>Blank Line</h5>
<p ${text_style}>Adds empty space for better visual separation.</p>
<h6 ${subtitle_style}>Use For:</h6>
<ul ${list_style}>
<li>Separating major sections</li>
<li>Adding space before totals</li>
</ul>
<p ${note_style}><strong>Note:</strong> No formula needed - creates visual spacing only.</p>
</div>`;
} else if (data_source === "Column Break") {
description_html = `
<div ${container_style}>
<h5 ${title_style}>Column Break</h5>
<p ${text_style}>Creates a visual break for side-by-side layout.</p>
<h6 ${subtitle_style}>Use For:</h6>
<ul ${list_style}>
<li>Horizontal P&L statements</li>
<li>Side-by-side Balance Sheet sections</li>
</ul>
<p ${note_style}><strong>Note:</strong> No formula needed - this is for formatting only.</p>
</div>`;
} else if (data_source === "Section Break") {
description_html = `
<div ${container_style}>
<h5 ${title_style}>Section Break</h5>
<p ${text_style}>Creates a visual break for separating different sections.</p>
<h6 ${subtitle_style}>Use For:</h6>
<ul ${list_style}>
<li>Separating major sections in a report - say trading & profit and loss</li>
<li>Improving readability by adding space</li>
</ul>
<p ${note_style}><strong>Note:</strong> No formula needed - this is for formatting only.</p>
</div>`;
}
grid.update_docfield_property("formula_description", "options", description_html);
}

View File

@@ -1,102 +0,0 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "field:template_name",
"creation": "2025-08-02 04:44:15.184541",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"template_name",
"report_type",
"module",
"column_break_lvnq",
"disabled",
"section_break_fvlw",
"rows"
],
"fields": [
{
"description": "Descriptive name for your template (e.g., 'Standard P&L', 'Detailed Balance Sheet')",
"fieldname": "template_name",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Template Name",
"reqd": 1,
"unique": 1
},
{
"description": "Type of financial statement this template generates",
"fieldname": "report_type",
"fieldtype": "Select",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Report Type",
"options": "\nProfit and Loss Statement\nBalance Sheet\nCash Flow\nCustom Financial Statement"
},
{
"depends_on": "eval:frappe.boot.developer_mode",
"fieldname": "module",
"fieldtype": "Link",
"label": "Module (for Export)",
"options": "Module Def"
},
{
"fieldname": "column_break_lvnq",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_fvlw",
"fieldtype": "Section Break"
},
{
"allow_bulk_edit": 1,
"fieldname": "rows",
"fieldtype": "Table",
"label": "Report Line Items",
"options": "Financial Report Row"
},
{
"default": "0",
"description": "Disable template to prevent use in reports",
"fieldname": "disabled",
"fieldtype": "Check",
"label": "Disabled"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-11-14 00:11:03.508139",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Financial Report Template",
"naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Accounts Manager",
"share": 1,
"write": 1
},
{
"read": 1,
"role": "Accounts User"
},
{
"read": 1,
"role": "Auditor"
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"title_field": "template_name"
}

View File

@@ -1,179 +0,0 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import os
import shutil
import frappe
from frappe import _
from frappe.model.document import Document
from erpnext.accounts.doctype.account_category.account_category import import_account_categories
from erpnext.accounts.doctype.financial_report_template.financial_report_validation import TemplateValidator
class FinancialReportTemplate(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frappe.types import DF
from erpnext.accounts.doctype.financial_report_row.financial_report_row import FinancialReportRow
disabled: DF.Check
module: DF.Link | None
report_type: DF.Literal[
"", "Profit and Loss Statement", "Balance Sheet", "Cash Flow", "Custom Financial Statement"
]
rows: DF.Table[FinancialReportRow]
template_name: DF.Data
# end: auto-generated types
def validate(self):
validator = TemplateValidator(self)
result = validator.validate()
result.notify_user()
def on_update(self):
self._export_template()
def on_trash(self):
self._delete_template()
def _export_template(self):
from frappe.modules.utils import export_module_json
if not self.module:
return
export_module_json(self, True, self.module)
self._export_account_categories()
def _delete_template(self):
if not self.module or not frappe.conf.developer_mode:
return
module_path = frappe.get_module_path(self.module)
dir_path = os.path.join(module_path, "financial_report_template", frappe.scrub(self.name))
shutil.rmtree(dir_path, ignore_errors=True)
def _export_account_categories(self):
import json
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
FormulaFieldExtractor,
)
if not self.module or not frappe.conf.developer_mode or frappe.flags.in_import:
return
# Extract category from rows
extractor = FormulaFieldExtractor(
field_name="account_category", exclude_operators=["like", "not like"]
)
account_data_rows = [row for row in self.rows if row.data_source == "Account Data"]
category_names = extractor.extract_from_rows(account_data_rows)
if not category_names:
return
# Get path
module_path = frappe.get_module_path(self.module)
categories_file = os.path.join(module_path, "financial_report_template", "account_categories.json")
# Load existing categories
existing_categories = {}
if os.path.exists(categories_file):
try:
with open(categories_file) as f:
existing_data = json.load(f)
existing_categories = {cat["account_category_name"]: cat for cat in existing_data}
except (json.JSONDecodeError, KeyError):
pass # Create new file
# Fetch categories from database
if category_names:
db_categories = frappe.get_all(
"Account Category",
filters={"account_category_name": ["in", list(category_names)]},
fields=["account_category_name", "description"],
)
for cat in db_categories:
existing_categories[cat["account_category_name"]] = cat
# Sort by category name
sorted_categories = sorted(existing_categories.values(), key=lambda x: x["account_category_name"])
# Write to file
os.makedirs(os.path.dirname(categories_file), exist_ok=True)
with open(categories_file, "w") as f:
json.dump(sorted_categories, f, indent=2)
def sync_financial_report_templates(chart_of_accounts=None, existing_company=None):
from erpnext.accounts.doctype.account.chart_of_accounts.chart_of_accounts import get_chart
# If COA is being created for an existing company,
# skip syncing templates as they are likely already present
if existing_company:
return
# Allow regional templates to completely override ERPNext
# templates based on the chart of accounts selected
disable_default_financial_report_template = False
if chart_of_accounts:
coa = get_chart(chart_of_accounts)
if coa.get("disable_default_financial_report_template", False):
disable_default_financial_report_template = True
installed_apps = frappe.get_installed_apps()
for app in installed_apps:
if disable_default_financial_report_template and app == "erpnext":
continue
_sync_templates_for(app)
def _sync_templates_for(app_name):
templates = []
for module_name in frappe.local.app_modules.get(app_name) or []:
module_path = frappe.get_module_path(module_name)
template_path = os.path.join(module_path, "financial_report_template")
if not os.path.isdir(template_path):
continue
import_account_categories(template_path)
for template_dir in os.listdir(template_path):
json_file = os.path.join(template_path, template_dir, f"{template_dir}.json")
if os.path.isfile(json_file):
templates.append(json_file)
if not templates:
return
# ensure files are not exported
frappe.flags.in_import = True
for template_path in templates:
with open(template_path) as f:
template_data = frappe._dict(frappe.parse_json(f.read()))
template_name = template_data.get("name")
if not frappe.db.exists("Financial Report Template", template_name):
doc = frappe.get_doc(template_data)
doc.flags.ignore_mandatory = True
doc.flags.ignore_permissions = True
doc.flags.ignore_validate = True
doc.insert()
frappe.flags.in_import = False

View File

@@ -1,545 +0,0 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import ast
import json
import re
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, ClassVar
import frappe
from frappe import _
from frappe.database.operator_map import OPERATOR_MAP
from frappe.database.query import SQLFunctionParser
@dataclass
class ValidationIssue:
"""Represents a single validation issue"""
message: str
row_idx: int | None = None
field: str | None = None
details: dict[str, Any] = None
def __post_init__(self):
if self.details is None:
self.details = {}
def __str__(self) -> str:
prefix = f"Row {self.row_idx}: " if self.row_idx else ""
field_info = f"[{self.field}] " if self.field else ""
message = f"{prefix}{field_info}{self.message}"
return _(message)
@dataclass
class ValidationResult:
issues: list[ValidationIssue] = field(default_factory=list)
warnings: list[ValidationIssue] = field(default_factory=list)
@property
def is_valid(self) -> bool:
return len(self.issues) == 0
@property
def has_warnings(self) -> bool:
return len(self.warnings) > 0
@property
def error_count(self) -> int:
return len(self.issues)
@property
def warning_count(self) -> int:
return len(self.warnings)
def merge(self, other: "ValidationResult") -> "ValidationResult":
self.issues.extend(other.issues)
self.warnings.extend(other.warnings)
return self
def add_error(self, issue: ValidationIssue) -> None:
"""Add a critical error that prevents functionality"""
self.issues.append(issue)
def add_warning(self, issue: ValidationIssue) -> None:
"""Add a warning for recommendatory validation"""
self.warnings.append(issue)
def notify_user(self) -> None:
warnings = "<br><br>".join(str(w) for w in self.warnings)
errors = "<br><br>".join(str(e) for e in self.issues)
if warnings:
frappe.msgprint(warnings, title=_("Warnings"), indicator="orange")
if errors:
frappe.throw(errors, title=_("Errors"))
class TemplateValidator:
"""Main validator that orchestrates all validations"""
def __init__(self, template):
self.template = template
self.validators = [
TemplateStructureValidator(),
DependencyValidator(template),
]
self.formula_validator = FormulaValidator(template)
def validate(self) -> ValidationResult:
result = ValidationResult([])
# Run template-level validators
for validator in self.validators:
result.merge(validator.validate(self.template))
# Run row-level validations
account_fields = {field.fieldname for field in frappe.get_meta("Account").fields}
for row in self.template.rows:
result.merge(self.formula_validator.validate(row, account_fields))
return result
class Validator(ABC):
@abstractmethod
def validate(self, context: Any) -> ValidationResult:
pass
class TemplateStructureValidator(Validator):
def validate(self, template) -> ValidationResult:
result = ValidationResult()
result.merge(self._validate_reference_codes(template))
result.merge(self._validate_required_fields(template))
return result
def _validate_reference_codes(self, template) -> ValidationResult:
result = ValidationResult()
used_codes = set()
for row in template.rows:
if not row.reference_code:
continue
ref_code = row.reference_code.strip()
# Check format
if not re.match(r"^[A-Za-z][A-Za-z0-9_-]*$", ref_code):
result.add_error(
ValidationIssue(
message=f"Invalid line reference format: '{ref_code}'. Must start with letter and contain only letters, numbers, underscores, and hyphens",
row_idx=row.idx,
)
)
# Check uniqueness
if ref_code in used_codes:
result.add_error(
ValidationIssue(
message=f"Duplicate line reference: '{ref_code}'",
row_idx=row.idx,
)
)
used_codes.add(ref_code)
return result
def _validate_required_fields(self, template) -> ValidationResult:
result = ValidationResult()
for row in template.rows:
# Balance type required
if row.data_source == "Account Data" and not row.balance_type:
result.add_error(
ValidationIssue(
message="Balance Type is required for Account Data",
row_idx=row.idx,
)
)
# Calculation formula required
if row.data_source in ["Account Data", "Calculated Amount", "Custom API"]:
if not row.calculation_formula:
result.add_error(
ValidationIssue(
message=f"Formula is required for {row.data_source}",
row_idx=row.idx,
)
)
return result
class DependencyValidator(Validator):
def __init__(self, template):
self.template = template
self.dependencies = self._build_dependency_graph()
def validate(self, context=None) -> ValidationResult:
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 = {}
available_codes = {row.reference_code for row in self.template.rows if row.reference_code}
for row in self.template.rows:
if row.reference_code and row.data_source == "Calculated Amount" and row.calculation_formula:
deps = extract_reference_codes_from_formula(row.calculation_formula, list(available_codes))
if deps:
graph[row.reference_code] = deps
return graph
def _validate_circular_dependencies(self) -> ValidationResult:
"""
Efficient cycle detection using DFS (Depth-First Search) with three-color algorithm:
- WHITE (0): unvisited node
- GRAY (1): currently being processed (on recursion stack)
- BLACK (2): fully processed
Example cycle detection:
A → B → C → A (cycle detected when A is GRAY and visited again)
"""
result = ValidationResult()
WHITE, GRAY, BLACK = 0, 1, 2
colors = {node: WHITE for node in self.dependencies}
def dfs(node, path):
if node not in colors:
return # External dependency
if colors[node] == GRAY:
# Found cycle
cycle_start = path.index(node)
cycle = [*path[cycle_start:], node]
result.add_error(
ValidationIssue(
message=f"Circular dependency detected: {''.join(cycle)}",
)
)
return
if colors[node] == BLACK:
return # Already processed
colors[node] = GRAY
path.append(node)
for neighbor in self.dependencies.get(node, []):
dfs(neighbor, path.copy())
colors[node] = BLACK
for node in self.dependencies:
if colors[node] == WHITE:
dfs(node, [])
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=f"Line References undefined in Formula: {', '.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"""
def __init__(self, reference_codes: set[str]):
self.reference_codes = reference_codes
def validate(self, row) -> ValidationResult:
"""Validate calculation formula for a single row"""
result = ValidationResult()
if row.data_source != "Calculated Amount":
return result
if not row.calculation_formula:
result.add_error(
ValidationIssue(
message="Formula is required for Calculated Amount",
row_idx=row.idx,
field="Formula",
)
)
return result
formula = self._preprocess_formula(row.calculation_formula)
row.calculation_formula = formula
# Check parentheses
if not self._are_parentheses_balanced(formula):
result.add_error(
ValidationIssue(
message="Formula has unbalanced parentheses",
row_idx=row.idx,
)
)
return result
# Check self-reference
available_codes = list(self.reference_codes)
refs = extract_reference_codes_from_formula(formula, available_codes)
if row.reference_code and row.reference_code in refs:
result.add_error(
ValidationIssue(
message=f"Formula references itself ('{row.reference_code}')",
row_idx=row.idx,
)
)
# Check undefined references
undefined = set(refs) - set(available_codes)
if undefined:
result.add_error(
ValidationIssue(
message=f"Formula references undefined codes: {', '.join(undefined)}",
row_idx=row.idx,
)
)
# Try to evaluate with dummy values
eval_error = self._test_formula_evaluation(formula, available_codes)
if eval_error:
result.add_error(
ValidationIssue(
message=f"Formula evaluation error: {eval_error}",
row_idx=row.idx,
)
)
return result
def _preprocess_formula(self, formula: str) -> str:
if not formula or not isinstance(formula, str):
return ""
return formula.strip()
@staticmethod
def _are_parentheses_balanced(formula: str) -> bool:
return formula.count("(") == formula.count(")")
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(
{
"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": lambda x: int(x),
}
)
result = frappe.safe_eval(formula, eval_globals=None, eval_locals=context)
if not isinstance(result, (int, float)): # noqa: UP038
return f"Formula must return a numeric value, got {type(result).__name__}"
return None
except Exception as e:
return str(e)
class AccountFilterValidator(Validator):
"""Validates account filter expressions used in Account Data rows"""
def __init__(self, account_fields: set | None = None):
self.account_fields = account_fields or set(frappe.get_meta("Account")._valid_columns)
def validate(self, row) -> ValidationResult:
result = ValidationResult()
if row.data_source != "Account Data":
return result
if not row.calculation_formula:
result.add_error(
ValidationIssue(
message="Account filter is required for Account Data",
row_idx=row.idx,
field="Formula",
)
)
return result
try:
filter_config = json.loads(row.calculation_formula)
error = self._validate_filter_structure(filter_config, self.account_fields)
if error:
result.add_error(
ValidationIssue(
message=error,
row_idx=row.idx,
field="Account Filter",
)
)
except json.JSONDecodeError as e:
result.add_error(
ValidationIssue(
message=f"Invalid JSON format: {e!s}",
row_idx=row.idx,
field="Account Filter",
)
)
return result
def _validate_filter_structure(self, filter_config, account_fields: set) -> str | None:
# simple condition: [field, operator, value]
if isinstance(filter_config, list):
if len(filter_config) != 3:
return "Filter must be [field, operator, value]"
field, operator, value = filter_config
if not isinstance(field, str) or not isinstance(operator, str):
return "Field and operator must be strings"
if field not in account_fields:
return f"Field '{field}' is not a valid account field"
if operator.casefold() not in OPERATOR_MAP:
return f"Invalid operator '{operator}'"
if operator in ["in", "not in"] and not isinstance(value, list):
return f"Operator '{operator}' requires a list value"
# logical condition: {"and": [condition1, condition2]}
elif isinstance(filter_config, dict):
if len(filter_config) != 1:
return "Logical condition must have exactly one operator"
op = next(iter(filter_config.keys())).lower()
if op not in ["and", "or"]:
return "Logical operators must be 'and' or 'or'"
conditions = filter_config[next(iter(filter_config.keys()))]
if not isinstance(conditions, list) or len(conditions) < 1:
return "Logical conditions need at least 1 sub-condition"
# recursive
for condition in conditions:
error = self._validate_filter_structure(condition, account_fields)
if error:
return error
else:
return "Filter must be a list or dict"
return None
class FormulaValidator(Validator):
def __init__(self, template):
self.template = template
reference_codes = {row.reference_code for row in template.rows if row.reference_code}
self.calculation_validator = CalculationFormulaValidator(reference_codes)
self.account_filter_validator = AccountFilterValidator()
def validate(self, row, account_fields: set) -> ValidationResult:
result = ValidationResult()
if not row.calculation_formula:
return result
if row.data_source == "Calculated Amount":
return self.calculation_validator.validate(row)
elif row.data_source == "Account Data":
# Update account fields if provided
if account_fields:
self.account_filter_validator.account_fields = account_fields
return self.account_filter_validator.validate(row)
elif row.data_source == "Custom API":
result.merge(self._validate_custom_api(row))
return result
def _validate_custom_api(self, row) -> ValidationResult:
result = ValidationResult()
api_path = row.calculation_formula
if "." not in api_path:
result.add_error(
ValidationIssue(
message="Custom API path should be in format: app.module.method",
row_idx=row.idx,
field="Formula",
)
)
return result
# Method exists?
try:
module_path, method_name = api_path.rsplit(".", 1)
module = frappe.get_module(module_path)
if not hasattr(module, method_name):
result.add_error(
ValidationIssue(
message=f"Method '{method_name}' not found in module '{module_path}' (might be environment-specific)",
row_idx=row.idx,
field="Formula",
)
)
except Exception as e:
result.add_error(
ValidationIssue(
message=f"Could not validate API path: {e!s}",
row_idx=row.idx,
field="Formula",
)
)
return result
def extract_reference_codes_from_formula(formula: str, available_codes: list[str]) -> list[str]:
found_codes = []
for code in available_codes:
# Match complete words only to avoid partial matches
pattern = r"\b" + re.escape(code) + r"\b"
if re.search(pattern, formula):
found_codes.append(code)
return found_codes

View File

@@ -1,79 +0,0 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
from frappe.tests import IntegrationTestCase
from frappe.tests.utils import make_test_records
# On IntegrationTestCase, the doctype test records and all
# link-field test record dependencies are recursively loaded
# Use these module variables to add/remove to/from that list
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
class TestFinancialReportTemplate(IntegrationTestCase):
pass
class FinancialReportTemplateTestCase(IntegrationTestCase):
"""Utility class with common setup and helper methods for all test classes"""
@classmethod
def setUpClass(cls):
"""Set up test data"""
make_test_records("Company")
make_test_records("Fiscal Year")
cls.create_test_template()
@classmethod
def create_test_template(cls):
"""Create a test financial report template"""
if not frappe.db.exists("Financial Report Template", "Test P&L Template"):
template = frappe.get_doc(
{
"doctype": "Financial Report Template",
"template_name": "Test P&L Template",
"report_type": "Profit and Loss Statement",
"rows": [
{
"reference_code": "INC001",
"display_name": "Income",
"indentation_level": 0,
"data_source": "Account Data",
"balance_type": "Closing Balance",
"bold_text": 1,
"calculation_formula": '["root_type", "=", "Income"]',
},
{
"reference_code": "EXP001",
"display_name": "Expenses",
"indentation_level": 0,
"data_source": "Account Data",
"balance_type": "Closing Balance",
"bold_text": 1,
"calculation_formula": '["root_type", "=", "Expense"]',
},
{
"reference_code": "NET001",
"display_name": "Net Profit/Loss",
"indentation_level": 0,
"data_source": "Calculated Amount",
"bold_text": 1,
"calculation_formula": "INC001 - EXP001",
},
],
}
)
template.insert()
cls.test_template = frappe.get_doc("Financial Report Template", "Test P&L Template")
@staticmethod
def create_test_template_with_rows(rows_data):
"""Helper method to create test template with specific rows"""
template_name = f"Test Template {frappe.generate_hash()[:8]}"
template = frappe.get_doc(
{"doctype": "Financial Report Template", "template_name": template_name, "rows": rows_data}
)
return template

View File

@@ -4,7 +4,6 @@ from frappe import _
def get_data():
return {
"fieldname": "fiscal_year",
"non_standard_fieldnames": {"Budget": "from_fiscal_year"},
"transactions": [
{"label": _("Budgets"), "items": ["Budget"]},
{"label": _("References"), "items": ["Period Closing Voucher"]},

View File

@@ -193,6 +193,7 @@ class GLEntry(Document):
account_type == "Profit and Loss"
and self.company == dimension.company
and dimension.mandatory_for_pl
and not dimension.disabled
and not self.is_cancelled
):
if not self.get(dimension.fieldname):
@@ -206,6 +207,7 @@ class GLEntry(Document):
account_type == "Balance Sheet"
and self.company == dimension.company
and dimension.mandatory_for_bs
and not dimension.disabled
and not self.is_cancelled
):
if not self.get(dimension.fieldname):
@@ -440,7 +442,7 @@ def update_against_account(voucher_type, voucher_no):
if not entries:
return
company_currency = erpnext.get_company_currency(entries[0].company)
precision = get_field_precision(frappe.get_meta("GL Entry").get_field("debit"), currency=company_currency)
precision = get_field_precision(frappe.get_meta("GL Entry").get_field("debit"), company_currency)
accounts_debited, accounts_credited = [], []
for d in entries:

View File

@@ -9,8 +9,8 @@ frappe.listview_settings["Invoice Discounting"] = {
return [__("Disbursed"), "blue", "status,=,Disbursed"];
} else if (doc.status == "Settled") {
return [__("Settled"), "orange", "status,=,Settled"];
} else if (doc.status == "Cancelled") {
return [__("Cancelled"), "red", "status,=,Cancelled"];
} else if (doc.status == "Canceled") {
return [__("Canceled"), "red", "status,=,Canceled"];
}
},
};

View File

@@ -43,20 +43,6 @@ frappe.ui.form.on("Journal Entry", {
},
};
});
frm.set_query("project", "accounts", function (doc, cdt, cdn) {
let row = frappe.get_doc(cdt, cdn);
let filters = {
company: doc.company,
};
if (row.party_type == "Customer") {
filters.customer = row.party;
}
return {
query: "erpnext.controllers.queries.get_project_name",
filters,
};
});
},
get_balance_for_periodic_accounting(frm) {
@@ -125,12 +111,6 @@ frappe.ui.form.on("Journal Entry", {
}
erpnext.accounts.unreconcile_payment.add_unreconcile_btn(frm);
if (frm.doc.voucher_type !== "Exchange Gain Or Loss") {
$.each(frm.doc.accounts || [], function (i, row) {
erpnext.journal_entry.set_exchange_rate(frm, row.doctype, row.name);
});
}
},
before_save: function (frm) {
if (frm.doc.docstatus == 0 && !frm.doc.is_system_generated) {
@@ -217,7 +197,6 @@ frappe.ui.form.on("Journal Entry", {
erpnext.accounts.dimensions.update_dimension(frm, frm.doctype);
erpnext.utils.set_letter_head(frm);
frm.clear_table("tax_withholding_entries");
},
voucher_type: function (frm) {
@@ -268,10 +247,6 @@ frappe.ui.form.on("Journal Entry", {
});
}
},
apply_tds: function (frm) {
frm.clear_table("tax_withholding_entries");
},
});
var update_jv_details = function (doc, r) {
@@ -741,8 +716,6 @@ $.extend(erpnext.journal_entry, {
}
},
});
} else {
erpnext.journal_entry.clear_fields(frm, dt, dn);
}
},
set_amount_on_last_row: function (frm, dt, dn) {
@@ -767,13 +740,4 @@ $.extend(erpnext.journal_entry, {
}
refresh_field("accounts");
},
clear_fields: function (frm, dt, dn) {
let row = locals[dt][dn];
row.party_type = null;
row.party = null;
row.bank_account = null;
frm.refresh_field("accounts");
},
});

View File

@@ -43,11 +43,6 @@
"total_amount_currency",
"total_amount",
"total_amount_in_words",
"section_tax_withholding_entry",
"tax_withholding_group",
"ignore_tax_withholding_threshold",
"override_tax_withholding_entries",
"tax_withholding_entries",
"reference",
"clearance_date",
"remark",
@@ -522,7 +517,7 @@
"depends_on": "eval:['Credit Note', 'Debit Note'].includes(doc.voucher_type)",
"fieldname": "apply_tds",
"fieldtype": "Check",
"label": "Consider for Tax Withholding "
"label": "Apply Tax Withholding Amount "
},
{
"depends_on": "eval:doc.docstatus",
@@ -591,39 +586,6 @@
"hidden": 1,
"label": "Party Not Required",
"no_copy": 1
},
{
"collapsible": 1,
"collapsible_depends_on": "eval: doc.apply_tds && doc.docstatus == 0",
"depends_on": "eval: doc.apply_tds",
"fieldname": "section_tax_withholding_entry",
"fieldtype": "Section Break",
"label": "Tax Withholding Entry"
},
{
"fieldname": "tax_withholding_group",
"fieldtype": "Link",
"label": "Tax Withholding Group",
"options": "Tax Withholding Group"
},
{
"default": "0",
"fieldname": "ignore_tax_withholding_threshold",
"fieldtype": "Check",
"label": "Ignore Tax Withholding Threshold"
},
{
"default": "0",
"fieldname": "override_tax_withholding_entries",
"fieldtype": "Check",
"label": "Edit Tax Withholding Entries"
},
{
"fieldname": "tax_withholding_entries",
"fieldtype": "Table",
"label": "Tax Withholding Entries",
"options": "Tax Withholding Entry",
"read_only_depends_on": "eval: !doc.override_tax_withholding_entries"
}
],
"icon": "fa fa-file-text",
@@ -638,7 +600,7 @@
"table_fieldname": "payment_entries"
}
],
"modified": "2025-11-13 17:54:14.542903",
"modified": "2025-09-29 13:05:46.982277",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Journal Entry",

View File

@@ -6,7 +6,6 @@ import json
import frappe
from frappe import _, msgprint, scrub
from frappe.core.doctype.submission_queue.submission_queue import queue_submission
from frappe.utils import comma_and, cstr, flt, fmt_money, formatdate, get_link_to_form, nowdate
import erpnext
@@ -18,7 +17,9 @@ from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger
validate_docs_for_deferred_accounting,
validate_docs_for_voucher_types,
)
from erpnext.accounts.doctype.tax_withholding_entry.tax_withholding_entry import JournalTaxWithholding
from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category import (
get_party_tax_withholding_details,
)
from erpnext.accounts.party import get_party_account
from erpnext.accounts.utils import (
cancel_exchange_gain_loss_journal,
@@ -32,7 +33,6 @@ from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_sched
get_depr_schedule,
)
from erpnext.controllers.accounts_controller import AccountsController
from erpnext.setup.utils import get_exchange_rate as _get_exchange_rate
class StockAccountInvalidTransaction(frappe.ValidationError):
@@ -49,7 +49,6 @@ class JournalEntry(AccountsController):
from frappe.types import DF
from erpnext.accounts.doctype.journal_entry_account.journal_entry_account import JournalEntryAccount
from erpnext.accounts.doctype.tax_withholding_entry.tax_withholding_entry import TaxWithholdingEntry
accounts: DF.Table[JournalEntryAccount]
amended_from: DF.Link | None
@@ -66,7 +65,6 @@ class JournalEntry(AccountsController):
finance_book: DF.Link | None
for_all_stock_asset_accounts: DF.Check
from_template: DF.Link | None
ignore_tax_withholding_threshold: DF.Check
inter_company_journal_entry_reference: DF.Link | None
is_opening: DF.Literal["No", "Yes"]
is_system_generated: DF.Check
@@ -75,7 +73,6 @@ class JournalEntry(AccountsController):
multi_currency: DF.Check
naming_series: DF.Literal["ACC-JV-.YYYY.-"]
party_not_required: DF.Check
override_tax_withholding_entries: DF.Check
pay_to_recd_from: DF.Data | None
payment_order: DF.Link | None
periodic_entry_difference_account: DF.Link | None
@@ -87,8 +84,6 @@ class JournalEntry(AccountsController):
stock_asset_account: DF.Link | None
stock_entry: DF.Link | None
tax_withholding_category: DF.Link | None
tax_withholding_entries: DF.Table[TaxWithholdingEntry]
tax_withholding_group: DF.Link | None
title: DF.Data | None
total_amount: DF.Currency
total_amount_currency: DF.Link | None
@@ -155,8 +150,8 @@ class JournalEntry(AccountsController):
self.validate_company_in_accounting_dimension()
self.validate_advance_accounts()
JournalTaxWithholding(self).on_validate()
if self.docstatus == 0:
self.apply_tax_withholding()
if self.is_new() or not self.title:
self.title = self.get_title()
@@ -180,16 +175,15 @@ class JournalEntry(AccountsController):
def submit(self):
if len(self.accounts) > 100:
queue_submission(self, "_submit")
msgprint(_("The task has been enqueued as a background job."), alert=True)
self.queue_action("submit", timeout=4600)
else:
return self._submit()
def before_cancel(self):
self.has_asset_adjustment_entry()
def cancel(self):
if len(self.accounts) > 100:
queue_submission(self, "_cancel")
msgprint(_("The task has been enqueued as a background job."), alert=True)
self.queue_action("cancel", timeout=4600)
else:
return self._cancel()
@@ -205,7 +199,6 @@ class JournalEntry(AccountsController):
self.update_asset_value()
self.update_inter_company_jv()
self.update_invoice_discounting()
JournalTaxWithholding(self).on_submit()
@frappe.whitelist()
def get_balance_for_periodic_accounting(self):
@@ -289,8 +282,6 @@ class JournalEntry(AccountsController):
self.repost_accounting_entries()
def on_cancel(self):
# Cancel tax withholding entries
# References for this Journal are removed on the `on_cancel` event in accounts_controller
super().on_cancel()
self.ignore_linked_doctypes = (
@@ -304,10 +295,8 @@ class JournalEntry(AccountsController):
"Unreconcile Payment",
"Unreconcile Payment Entries",
"Advance Payment Ledger Entry",
"Tax Withholding Entry",
)
self.make_gl_entries(1)
JournalTaxWithholding(self).on_cancel()
self.unlink_advance_entry_reference()
self.unlink_asset_reference()
self.unlink_inter_company_jv()
@@ -363,6 +352,95 @@ class JournalEntry(AccountsController):
StockAccountInvalidTransaction,
)
def apply_tax_withholding(self):
from erpnext.accounts.report.general_ledger.general_ledger import get_account_type_map
if not self.apply_tds or self.voucher_type not in ("Debit Note", "Credit Note"):
return
parties = [d.party for d in self.get("accounts") if d.party]
parties = list(set(parties))
if len(parties) > 1:
frappe.throw(_("Cannot apply TDS against multiple parties in one entry"))
account_type_map = get_account_type_map(self.company)
party_type = "supplier" if self.voucher_type == "Credit Note" else "customer"
doctype = "Purchase Invoice" if self.voucher_type == "Credit Note" else "Sales Invoice"
debit_or_credit = (
"debit_in_account_currency"
if self.voucher_type == "Credit Note"
else "credit_in_account_currency"
)
rev_debit_or_credit = (
"credit_in_account_currency"
if debit_or_credit == "debit_in_account_currency"
else "debit_in_account_currency"
)
party_account = get_party_account(party_type.title(), parties[0], self.company)
net_total = sum(
d.get(debit_or_credit)
for d in self.get("accounts")
if account_type_map.get(d.account) not in ("Tax", "Chargeable")
)
party_amount = sum(
d.get(rev_debit_or_credit) for d in self.get("accounts") if d.account == party_account
)
inv = frappe._dict(
{
party_type: parties[0],
"doctype": doctype,
"company": self.company,
"posting_date": self.posting_date,
"net_total": net_total,
}
)
tax_withholding_details, advance_taxes, voucher_wise_amount = get_party_tax_withholding_details(
inv, self.tax_withholding_category
)
if not tax_withholding_details:
return
accounts = []
for d in self.get("accounts"):
if d.get("account") == tax_withholding_details.get("account_head"):
d.update(
{
"account": tax_withholding_details.get("account_head"),
debit_or_credit: tax_withholding_details.get("tax_amount"),
}
)
accounts.append(d.get("account"))
if d.get("account") == party_account:
d.update({rev_debit_or_credit: party_amount - tax_withholding_details.get("tax_amount")})
if not accounts or tax_withholding_details.get("account_head") not in accounts:
self.append(
"accounts",
{
"account": tax_withholding_details.get("account_head"),
rev_debit_or_credit: tax_withholding_details.get("tax_amount"),
"against_account": parties[0],
},
)
to_remove = [
d
for d in self.get("accounts")
if not d.get(rev_debit_or_credit) and d.account == tax_withholding_details.get("account_head")
]
for d in to_remove:
self.remove(d)
def update_asset_value(self):
self.update_asset_on_depreciation()
self.update_asset_on_disposal()
@@ -375,7 +453,7 @@ class JournalEntry(AccountsController):
if (
d.reference_type == "Asset"
and d.reference_name
and frappe.get_cached_value("Account", d.account, "root_type") == "Expense"
and d.account_type == "Depreciation"
and d.debit
):
asset = frappe.get_cached_doc("Asset", d.reference_name)
@@ -557,27 +635,12 @@ class JournalEntry(AccountsController):
)
frappe.db.set_value("Journal Entry", self.name, "inter_company_journal_entry_reference", "")
def has_asset_adjustment_entry(self):
if self.flags.get("via_asset_value_adjustment"):
return
asset_value_adjustment = frappe.db.get_value(
"Asset Value Adjustment", {"docstatus": 1, "journal_entry": self.name}, "name"
)
if asset_value_adjustment:
frappe.throw(
_(
"Cannot cancel this document as it is linked with the submitted Asset Value Adjustment <b>{0}</b>. Please cancel the Asset Value Adjustment to continue."
).format(frappe.utils.get_link_to_form("Asset Value Adjustment", asset_value_adjustment))
)
def unlink_asset_adjustment_entry(self):
AssetValueAdjustment = frappe.qb.DocType("Asset Value Adjustment")
(
frappe.qb.update(AssetValueAdjustment)
.set(AssetValueAdjustment.journal_entry, None)
.where(AssetValueAdjustment.journal_entry == self.name)
).run()
frappe.db.sql(
""" update `tabAsset Value Adjustment`
set journal_entry = null where journal_entry = %s""",
self.name,
)
def validate_party(self):
for d in self.get("accounts"):
@@ -1656,9 +1719,6 @@ def get_account_details_and_party_type(account, date, company, debit=None, credi
"party_type": party_type,
"account_type": account_details.account_type,
"account_currency": account_details.account_currency or company_currency,
"bank_account": (
frappe.db.get_value("Bank Account", {"account": account, "company": company}) or None
),
# The date used to retreive the exchange rate here is the date passed in
# as an argument to this function. It is assumed to be the date on which the balance is sought
"exchange_rate": get_exchange_rate(
@@ -1691,6 +1751,8 @@ def get_exchange_rate(
credit=None,
exchange_rate=None,
):
from erpnext.setup.utils import get_exchange_rate
account_details = frappe.get_cached_value(
"Account", account, ["account_type", "root_type", "account_currency", "company"], as_dict=1
)
@@ -1712,8 +1774,8 @@ def get_exchange_rate(
# The date used to retreive the exchange rate here is the date passed
# in as an argument to this function.
elif (not flt(exchange_rate) or flt(exchange_rate) == 1) and account_currency and posting_date:
exchange_rate = _get_exchange_rate(account_currency, company_currency, posting_date)
elif (not exchange_rate or flt(exchange_rate) == 1) and account_currency and posting_date:
exchange_rate = get_exchange_rate(account_currency, company_currency, posting_date)
else:
exchange_rate = 1

View File

@@ -34,7 +34,6 @@
"reference_detail_no",
"advance_voucher_type",
"advance_voucher_no",
"is_tax_withholding_account",
"col_break3",
"is_advance",
"user_remark",
@@ -282,19 +281,12 @@
"options": "advance_voucher_type",
"read_only": 1,
"search_index": 1
},
{
"default": "0",
"fieldname": "is_tax_withholding_account",
"fieldtype": "Check",
"label": "Is Tax Withholding Account",
"read_only": 1
}
],
"idx": 1,
"istable": 1,
"links": [],
"modified": "2025-11-27 12:23:33.157655",
"modified": "2025-10-27 13:48:32.805100",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Journal Entry Account",

View File

@@ -28,7 +28,6 @@ class JournalEntryAccount(Document):
debit_in_account_currency: DF.Currency
exchange_rate: DF.Float
is_advance: DF.Literal["No", "Yes"]
is_tax_withholding_account: DF.Check
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data

View File

@@ -7,7 +7,7 @@ frappe.ui.form.on("Mode of Payment", {
let d = locals[cdt][cdn];
return {
filters: [
["Account", "account_type", "in", ["Bank", "Cash", "Receivable"]],
["Account", "account_type", "in", "Bank, Cash, Receivable"],
["Account", "is_group", "=", 0],
["Account", "company", "=", d.company],
],

View File

@@ -11,5 +11,6 @@ def get_data():
},
"transactions": [
{"label": _("Target Details"), "items": ["Sales Person", "Territory", "Sales Partner"]},
{"items": ["Budget"]},
],
}

View File

@@ -71,8 +71,8 @@ class OpeningInvoiceCreationTool(Document):
max_count = {}
fields = [
"company",
{"COUNT": "*", "as": "total_invoices"},
{"SUM": "outstanding_amount", "as": "outstanding_amount"},
"count(name) as total_invoices",
"sum(outstanding_amount) as outstanding_amount",
]
companies = frappe.get_all("Company", fields=["name as company", "default_currency as currency"])
if not companies:
@@ -214,9 +214,6 @@ class OpeningInvoiceCreationTool(Document):
}
)
if self.invoice_type == "Purchase" and row.supplier_invoice_date:
invoice.update({"bill_date": row.supplier_invoice_date})
accounting_dimension = get_accounting_dimensions()
for dimension in accounting_dimension:
invoice.update({dimension: self.get(dimension) or item.get(dimension)})

View File

@@ -12,7 +12,6 @@
"column_break_3",
"posting_date",
"due_date",
"supplier_invoice_date",
"section_break_5",
"item_name",
"outstanding_amount",
@@ -112,26 +111,19 @@
"fieldname": "invoice_number",
"fieldtype": "Data",
"label": "Invoice Number"
},
{
"depends_on": "eval: parent.invoice_type == \"Purchase\"",
"fieldname": "supplier_invoice_date",
"fieldtype": "Date",
"label": "Supplier Invoice Date"
}
],
"istable": 1,
"links": [],
"modified": "2025-12-01 16:18:07.997594",
"modified": "2024-03-27 13:10:06.703006",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Opening Invoice Creation Tool Item",
"owner": "Administrator",
"permissions": [],
"quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -26,7 +26,6 @@ class OpeningInvoiceCreationToolItem(Document):
party_type: DF.Link | None
posting_date: DF.Date | None
qty: DF.Data | None
supplier_invoice_date: DF.Date | None
temporary_opening_account: DF.Link | None
# end: auto-generated types

View File

@@ -41,7 +41,6 @@ frappe.ui.form.on("Payment Entry", {
if (frm.is_new()) {
set_default_party_type(frm);
frm.clear_table("tax_withholding_entries");
}
},
@@ -182,7 +181,7 @@ frappe.ui.form.on("Payment Entry", {
"Dunning",
];
if (party_type_doctypes.includes(child.reference_doctype)) {
if (in_list(party_type_doctypes, child.reference_doctype)) {
filters[doc.party_type.toLowerCase()] = doc.party;
}
@@ -427,15 +426,7 @@ frappe.ui.form.on("Payment Entry", {
if (frm.doc.payment_type == "Internal Transfer") {
$.each(
[
"party",
"party_type",
"paid_from",
"paid_to",
"references",
"total_allocated_amount",
"party_name",
],
["party", "party_type", "paid_from", "paid_to", "references", "total_allocated_amount"],
function (i, field) {
frm.set_value(field, null);
}
@@ -541,7 +532,6 @@ frappe.ui.form.on("Payment Entry", {
},
() => frm.set_value("party_name", r.message.party_name),
() => frm.clear_table("references"),
() => frm.clear_table("tax_withholding_entries"),
() => frm.events.hide_unhide_fields(frm),
() => frm.events.set_dynamic_labels(frm),
() => {
@@ -574,22 +564,19 @@ frappe.ui.form.on("Payment Entry", {
}
},
apply_tds: function (frm) {
if (!frm.doc.apply_tds) {
apply_tax_withholding_amount: function (frm) {
if (!frm.doc.apply_tax_withholding_amount) {
frm.set_value("tax_withholding_category", "");
} else if (["Customer", "Supplier"].includes(frm.doc.party_type)) {
frappe.db.get_value(frm.doc.party_type, frm.doc.party, "tax_withholding_category", (values) => {
} else {
frappe.db.get_value("Supplier", frm.doc.party, "tax_withholding_category", (values) => {
frm.set_value("tax_withholding_category", values.tax_withholding_category);
});
}
frm.clear_table("tax_withholding_entries");
},
paid_from: function (frm) {
if (frm.set_party_account_based_on_party) return;
frm.events.set_company_bank_account(frm);
frm.events.set_account_currency_and_balance(
frm,
frm.doc.paid_from,
@@ -606,8 +593,6 @@ frappe.ui.form.on("Payment Entry", {
paid_to: function (frm) {
if (frm.set_party_account_based_on_party) return;
frm.events.set_company_bank_account(frm);
frm.events.set_account_currency_and_balance(
frm,
frm.doc.paid_to,
@@ -1041,7 +1026,7 @@ frappe.ui.form.on("Payment Entry", {
c.allocated_amount = d.allocated_amount;
c.account = d.account;
if (!frm.events.get_order_doctypes(frm).includes(d.voucher_type)) {
if (!in_list(frm.events.get_order_doctypes(frm), d.voucher_type)) {
if (flt(d.outstanding_amount) > 0)
total_positive_outstanding += flt(d.outstanding_amount);
else total_negative_outstanding += Math.abs(flt(d.outstanding_amount));
@@ -1057,7 +1042,7 @@ frappe.ui.form.on("Payment Entry", {
} else {
c.exchange_rate = 1;
}
if (frm.events.get_invoice_doctypes(frm).includes(d.reference_doctype)) {
if (in_list(frm.events.get_invoice_doctypes(frm), d.reference_doctype)) {
c.due_date = d.due_date;
}
});
@@ -1288,14 +1273,15 @@ frappe.ui.form.on("Payment Entry", {
let row = (frm.doc.deductions || []).find((t) => t.is_exchange_gain_loss);
if (!row) {
const company_defaults = frappe.get_doc(":Company", frm.doc.company);
const response = await get_company_defaults(frm.doc.company);
const account =
company_defaults?.[account_fieldname] ||
response.message?.[account_fieldname] ||
(await prompt_for_missing_account(frm, account_fieldname));
row = frm.add_child("deductions");
row.account = account;
row.cost_center = company_defaults?.cost_center;
row.cost_center = response.message?.cost_center;
row.is_exchange_gain_loss = 1;
}
@@ -1339,8 +1325,6 @@ frappe.ui.form.on("Payment Entry", {
},
bank_account: function (frm) {
if (frm.set_company_bank_account_based_on_coa) return;
const field = frm.doc.payment_type == "Pay" ? "paid_from" : "paid_to";
if (frm.doc.bank_account && ["Pay", "Receive"].includes(frm.doc.payment_type)) {
frappe.call({
@@ -1379,34 +1363,6 @@ frappe.ui.form.on("Payment Entry", {
}
},
set_company_bank_account: function (frm) {
if (!["Pay", "Receive"].includes(frm.doc.payment_type)) return;
const field = frm.doc.payment_type == "Pay" ? "paid_from" : "paid_to";
if (!frm.doc.company || !frm.doc[field]) return;
frm.set_company_bank_account_based_on_coa = true;
frappe.call({
method: "frappe.client.get_value",
args: {
doctype: "Bank Account",
filters: {
company: frm.doc.company,
account: frm.doc[field],
disabled: 0,
},
fieldname: ["name"],
},
callback: async function (r) {
if (r.message) await frm.set_value("bank_account", r.message.name);
frm.set_company_bank_account_based_on_coa = false;
},
});
},
sales_taxes_and_charges_template: function (frm) {
frm.trigger("fetch_taxes_from_template");
},
@@ -1465,6 +1421,7 @@ frappe.ui.form.on("Payment Entry", {
$.each(frm.doc["taxes"] || [], function (i, tax) {
frm.events.validate_taxes_and_charges(tax);
frm.events.validate_inclusive_tax(tax);
tax.item_wise_tax_detail = {};
let tax_fields = [
"total",
"tax_fraction_for_current_item",
@@ -1505,14 +1462,18 @@ frappe.ui.form.on("Payment Entry", {
"Can refer row only if the charge type is 'On Previous Row Amount' or 'Previous Row Total'"
);
d.row_id = "";
} else if (d.charge_type == "On Previous Row Amount" || d.charge_type == "On Previous Row Total") {
} else if (
(d.charge_type == "On Previous Row Amount" || d.charge_type == "On Previous Row Total") &&
d.row_id
) {
if (d.idx == 1) {
msg = __(
"Cannot select charge type as 'On Previous Row Amount' or 'On Previous Row Total' for first row"
);
d.charge_type = "";
} else if (!d.row_id) {
d.row_id = d.idx - 1;
msg = __("Please specify a valid Row ID for row {0} in table {1}", [d.idx, __(d.doctype)]);
d.row_id = "";
} else if (d.row_id && d.row_id >= d.idx) {
msg = __(
"Cannot refer row number greater than or equal to current row number for this Charge type"

View File

@@ -21,8 +21,6 @@
"party_name",
"book_advance_payments_in_separate_party_account",
"reconcile_on_advance_payment_date",
"apply_tds",
"tax_withholding_category",
"column_break_11",
"bank_account",
"party_bank_account",
@@ -62,6 +60,10 @@
"taxes_and_charges_section",
"purchase_taxes_and_charges_template",
"sales_taxes_and_charges_template",
"column_break_55",
"apply_tax_withholding_amount",
"tax_withholding_category",
"section_break_56",
"taxes",
"section_break_60",
"base_total_taxes_and_charges",
@@ -69,11 +71,6 @@
"total_taxes_and_charges",
"deductions_or_loss_section",
"deductions",
"section_tax_withholding_entry",
"tax_withholding_group",
"ignore_tax_withholding_threshold",
"override_tax_withholding_entries",
"tax_withholding_entries",
"transaction_references",
"reference_no",
"column_break_23",
@@ -581,17 +578,24 @@
"label": "Custom Remarks"
},
{
"depends_on": "eval:doc.apply_tds",
"depends_on": "eval:doc.apply_tax_withholding_amount",
"fieldname": "tax_withholding_category",
"fieldtype": "Link",
"label": "Tax Withholding Category",
"mandatory_depends_on": "eval:doc.apply_tds",
"mandatory_depends_on": "eval:doc.apply_tax_withholding_amount",
"options": "Tax Withholding Category"
},
{
"default": "0",
"depends_on": "eval:doc.party_type == 'Supplier'",
"fieldname": "apply_tax_withholding_amount",
"fieldtype": "Check",
"label": "Apply Tax Withholding Amount"
},
{
"collapsible": 1,
"fieldname": "taxes_and_charges_section",
"fieldtype": "Section Break",
"hide_border": 1,
"label": "Taxes and Charges"
},
{
@@ -644,6 +648,15 @@
"options": "Company:company:default_currency",
"read_only": 1
},
{
"fieldname": "column_break_55",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_56",
"fieldtype": "Section Break",
"hide_border": 1
},
{
"depends_on": "eval:doc.received_amount && doc.payment_type != 'Internal Transfer'",
"fieldname": "received_amount_after_tax",
@@ -682,7 +695,8 @@
},
{
"fieldname": "section_break_60",
"fieldtype": "Section Break"
"fieldtype": "Section Break",
"hide_border": 1
},
{
"depends_on": "eval:doc.docstatus==0",
@@ -739,46 +753,6 @@
"options": "No\nYes",
"print_hide": 1,
"search_index": 1
},
{
"default": "0",
"depends_on": "eval:doc.party_type == 'Supplier'",
"fieldname": "apply_tds",
"fieldtype": "Check",
"label": "Consider for Tax Withholding"
},
{
"collapsible": 1,
"collapsible_depends_on": "eval: doc.apply_tds && doc.docstatus == 0",
"depends_on": "eval: doc.apply_tds",
"fieldname": "section_tax_withholding_entry",
"fieldtype": "Section Break",
"label": "Tax Withholding Entry"
},
{
"fieldname": "tax_withholding_group",
"fieldtype": "Link",
"label": "Tax Withholding Group",
"options": "Tax Withholding Group"
},
{
"default": "0",
"fieldname": "ignore_tax_withholding_threshold",
"fieldtype": "Check",
"label": "Ignore Tax Withholding Threshold"
},
{
"fieldname": "tax_withholding_entries",
"fieldtype": "Table",
"label": "Tax Withholding Entries",
"options": "Tax Withholding Entry",
"read_only_depends_on": "eval: !doc.override_tax_withholding_entries"
},
{
"default": "0",
"fieldname": "override_tax_withholding_entries",
"fieldtype": "Check",
"label": "Edit Tax Withholding Entries"
}
],
"grid_page_length": 50,
@@ -793,7 +767,7 @@
"table_fieldname": "payment_entries"
}
],
"modified": "2025-12-18 13:56:40.206038",
"modified": "2025-05-08 11:18:10.238085",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Entry",

View File

@@ -30,7 +30,9 @@ from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger
validate_docs_for_deferred_accounting,
validate_docs_for_voucher_types,
)
from erpnext.accounts.doctype.tax_withholding_entry.tax_withholding_entry import PaymentTaxWithholding
from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category import (
get_party_tax_withholding_details,
)
from erpnext.accounts.general_ledger import (
make_gl_entries,
make_reverse_gl_entries,
@@ -78,10 +80,9 @@ class PaymentEntry(AccountsController):
from erpnext.accounts.doctype.payment_entry_reference.payment_entry_reference import (
PaymentEntryReference,
)
from erpnext.accounts.doctype.tax_withholding_entry.tax_withholding_entry import TaxWithholdingEntry
amended_from: DF.Link | None
apply_tds: DF.Check
apply_tax_withholding_amount: DF.Check
auto_repeat: DF.Link | None
bank: DF.ReadOnly | None
bank_account: DF.Link | None
@@ -102,13 +103,11 @@ class PaymentEntry(AccountsController):
custom_remarks: DF.Check
deductions: DF.Table[PaymentEntryDeduction]
difference_amount: DF.Currency
ignore_tax_withholding_threshold: DF.Check
in_words: DF.SmallText | None
is_opening: DF.Literal["No", "Yes"]
letter_head: DF.Link | None
mode_of_payment: DF.Link | None
naming_series: DF.Literal["ACC-PAY-.YYYY.-"]
override_tax_withholding_entries: DF.Check
paid_amount: DF.Currency
paid_amount_after_tax: DF.Currency
paid_from: DF.Link
@@ -140,8 +139,6 @@ class PaymentEntry(AccountsController):
status: DF.Literal["", "Draft", "Submitted", "Cancelled"]
target_exchange_rate: DF.Float
tax_withholding_category: DF.Link | None
tax_withholding_entries: DF.Table[TaxWithholdingEntry]
tax_withholding_group: DF.Link | None
taxes: DF.Table[AdvanceTaxesandCharges]
title: DF.Data | None
total_allocated_amount: DF.Currency
@@ -192,7 +189,7 @@ class PaymentEntry(AccountsController):
self.validate_allocated_amount()
self.validate_paid_invoices()
self.ensure_supplier_is_not_blocked()
PaymentTaxWithholding(self).on_validate()
self.set_tax_withholding()
self.set_status()
self.set_total_in_words()
@@ -202,7 +199,6 @@ class PaymentEntry(AccountsController):
def on_submit(self):
if self.difference_amount:
frappe.throw(_("Difference Amount must be zero"))
PaymentTaxWithholding(self).on_submit()
self.update_payment_requests()
self.update_payment_schedule()
self.make_gl_entries()
@@ -304,10 +300,8 @@ class PaymentEntry(AccountsController):
"Unreconcile Payment",
"Unreconcile Payment Entries",
"Advance Payment Ledger Entry",
"Tax Withholding Entry",
)
super().on_cancel()
PaymentTaxWithholding(self).on_cancel()
self.update_payment_requests(cancel=True)
self.update_payment_schedule(cancel=1)
self.make_gl_entries(cancel=1)
@@ -943,6 +937,93 @@ class PaymentEntry(AccountsController):
self.base_in_words = money_in_words(base_amount, self.company_currency)
self.in_words = money_in_words(amount, currency)
def set_tax_withholding(self):
if self.party_type != "Supplier":
return
if not self.apply_tax_withholding_amount:
return
net_total = self.calculate_tax_withholding_net_total()
# Adding args as purchase invoice to get TDS amount
args = frappe._dict(
{
"company": self.company,
"doctype": "Payment Entry",
"supplier": self.party,
"posting_date": self.posting_date,
"net_total": net_total,
}
)
tax_withholding_details = get_party_tax_withholding_details(args, self.tax_withholding_category)
if not tax_withholding_details:
return
tax_withholding_details.update(
{"cost_center": self.cost_center or erpnext.get_default_cost_center(self.company)}
)
accounts = []
for d in self.taxes:
if d.account_head == tax_withholding_details.get("account_head"):
# Preserve user updated included in paid amount
if d.included_in_paid_amount:
tax_withholding_details.update({"included_in_paid_amount": d.included_in_paid_amount})
d.update(tax_withholding_details)
accounts.append(d.account_head)
if not accounts or tax_withholding_details.get("account_head") not in accounts:
self.append("taxes", tax_withholding_details)
to_remove = [
d
for d in self.taxes
if not d.tax_amount and d.account_head == tax_withholding_details.get("account_head")
]
for d in to_remove:
self.remove(d)
def calculate_tax_withholding_net_total(self):
net_total = 0
order_details = self.get_order_wise_tax_withholding_net_total()
for d in self.references:
tax_withholding_net_total = order_details.get(d.reference_name)
if not tax_withholding_net_total:
continue
net_taxable_outstanding = max(
0, d.outstanding_amount - (d.total_amount - tax_withholding_net_total)
)
net_total += min(net_taxable_outstanding, d.allocated_amount)
net_total += self.unallocated_amount
return net_total
def get_order_wise_tax_withholding_net_total(self):
if self.party_type == "Supplier":
doctype = "Purchase Order"
else:
doctype = "Sales Order"
docnames = [d.reference_name for d in self.references if d.reference_doctype == doctype]
return frappe._dict(
frappe.db.get_all(
doctype,
filters={"name": ["in", docnames]},
fields=["name", "base_tax_withholding_net_total"],
as_list=True,
)
)
def apply_taxes(self):
self.initialize_taxes()
self.determine_exclusive_rate()
@@ -1285,11 +1366,8 @@ class PaymentEntry(AccountsController):
def make_gl_entries(self, cancel=0, adv_adj=0):
gl_entries = self.build_gl_map()
merge_entries = frappe.get_single_value("Accounts Settings", "merge_similar_account_heads")
gl_entries = process_gl_map(gl_entries, merge_entries=merge_entries)
make_gl_entries(gl_entries, cancel=cancel, adv_adj=adv_adj, merge_entries=merge_entries)
gl_entries = process_gl_map(gl_entries)
make_gl_entries(gl_entries, cancel=cancel, adv_adj=adv_adj)
if cancel:
cancel_exchange_gain_loss_journal(frappe._dict(doctype=self.doctype, name=self.name))
else:
@@ -1359,7 +1437,6 @@ class PaymentEntry(AccountsController):
else allocated_amount_in_company_currency / self.transaction_exchange_rate,
"advance_voucher_type": d.advance_voucher_type,
"advance_voucher_no": d.advance_voucher_no,
"transaction_exchange_rate": self.target_exchange_rate,
},
item=self,
)
@@ -1796,7 +1873,7 @@ class PaymentEntry(AccountsController):
else:
self.total_taxes_and_charges += current_tax_amount
self.base_total_taxes_and_charges += current_tax_amount
self.base_total_taxes_and_charges += tax.base_tax_amount
if self.get("taxes"):
self.paid_amount_after_tax = self.get("taxes")[-1].base_total

View File

@@ -1045,7 +1045,6 @@ class TestPaymentEntry(IntegrationTestCase):
)
def test_gl_of_multi_currency_payment_with_taxes(self):
frappe.db.set_single_value("Accounts Settings", "merge_similar_account_heads", 1)
payment_entry = create_payment_entry(
party="_Test Supplier USD", paid_to="_Test Payable USD - _TC", save=True
)
@@ -1607,96 +1606,6 @@ class TestPaymentEntry(IntegrationTestCase):
self.voucher_no = pe.name
self.check_gl_entries()
def test_payment_entry_merges_gl_entries_with_same_account_head(self):
"""
Test that Payment Entry merges GL entries with same account head
when 'Merge Similar Account Heads' setting is enabled.
"""
frappe.db.set_single_value("Accounts Settings", "merge_similar_account_heads", 1)
pe = create_payment_entry(
party_type="Supplier",
party="_Test Supplier",
paid_from="_Test Bank - _TC",
paid_to="Creditors - _TC",
)
pe.append(
"deductions",
{
"account": "Write Off - _TC",
"cost_center": "_Test Cost Center - _TC",
"amount": 50,
},
)
pe.append(
"deductions",
{
"account": "Write Off - _TC",
"cost_center": "_Test Cost Center - _TC",
"amount": 30,
},
)
pe.save()
pe.submit()
gl_entries = frappe.db.get_all(
"GL Entry",
filters={"voucher_no": pe.name, "account": "Write Off - _TC", "is_cancelled": 0},
fields=["debit", "credit"],
)
self.assertEqual(len(gl_entries), 1)
self.assertEqual(gl_entries[0].debit, 80)
def test_payment_entry_does_not_merge_gl_entries_when_setting_disabled(self):
"""
Test that Payment Entry does NOT merge GL entries
when 'Merge Similar Account Heads' is disabled.
"""
frappe.db.set_single_value("Accounts Settings", "merge_similar_account_heads", 0)
pe = create_payment_entry(
party_type="Supplier",
party="_Test Supplier",
paid_from="_Test Bank - _TC",
paid_to="Creditors - _TC",
)
pe.append(
"deductions",
{
"account": "Write Off - _TC",
"cost_center": "_Test Cost Center - _TC",
"amount": 50,
},
)
pe.append(
"deductions",
{
"account": "Write Off - _TC",
"cost_center": "_Test Cost Center - _TC",
"amount": 30,
},
)
pe.save()
pe.submit()
gl_entries = frappe.db.get_all(
"GL Entry",
filters={"voucher_no": pe.name, "account": "Write Off - _TC", "is_cancelled": 0},
fields=["debit", "credit"],
)
self.assertEqual(len(gl_entries), 2)
frappe.db.set_single_value("Accounts Settings", "merge_similar_account_heads", 1)
def check_pl_entries(self):
ple = frappe.qb.DocType("Payment Ledger Entry")
pl_entries = (

View File

@@ -59,15 +59,14 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-08-13 06:52:46.130142",
"modified": "2024-11-05 16:07:47.307971",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Entry Deduction",
"owner": "Administrator",
"permissions": [],
"quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}
}

View File

@@ -38,7 +38,7 @@
"search_index": 1
},
{
"columns": 4,
"columns": 2,
"fieldname": "reference_name",
"fieldtype": "Dynamic Link",
"in_global_search": 1,
@@ -49,10 +49,8 @@
"search_index": 1
},
{
"columns": 2,
"fieldname": "due_date",
"fieldtype": "Date",
"in_list_view": 1,
"label": "Due Date",
"read_only": 1
},
@@ -70,7 +68,7 @@
{
"columns": 2,
"fieldname": "total_amount",
"fieldtype": "Currency",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Grand Total",
"print_hide": 1,
@@ -79,7 +77,7 @@
{
"columns": 2,
"fieldname": "outstanding_amount",
"fieldtype": "Currency",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Outstanding",
"read_only": 1
@@ -87,7 +85,7 @@
{
"columns": 2,
"fieldname": "allocated_amount",
"fieldtype": "Currency",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Allocated"
},
@@ -176,7 +174,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-01-05 14:18:03.286224",
"modified": "2025-07-25 04:32:11.040025",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Entry Reference",

View File

@@ -18,12 +18,12 @@ class PaymentEntryReference(Document):
account_type: DF.Data | None
advance_voucher_no: DF.DynamicLink | None
advance_voucher_type: DF.Link | None
allocated_amount: DF.Currency
allocated_amount: DF.Float
bill_no: DF.Data | None
due_date: DF.Date | None
exchange_gain_loss: DF.Currency
exchange_rate: DF.Float
outstanding_amount: DF.Currency
outstanding_amount: DF.Float
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
@@ -34,7 +34,7 @@ class PaymentEntryReference(Document):
reconcile_effect_on: DF.Date | None
reference_doctype: DF.Link
reference_name: DF.DynamicLink
total_amount: DF.Currency
total_amount: DF.Float
# end: auto-generated types
@property

View File

@@ -131,6 +131,7 @@ class PaymentLedgerEntry(Document):
account_type == "Profit and Loss"
and self.company == dimension.company
and dimension.mandatory_for_pl
and not dimension.disabled
):
if not self.get(dimension.fieldname):
frappe.throw(
@@ -143,6 +144,7 @@ class PaymentLedgerEntry(Document):
account_type == "Balance Sheet"
and self.company == dimension.company
and dimension.mandatory_for_bs
and not dimension.disabled
):
if not self.get(dimension.fieldname):
frappe.throw(

View File

@@ -50,10 +50,12 @@ class TestPaymentOrder(IntegrationTestCase):
def create_payment_order_against_payment_entry(ref_doc, order_type, bank_account):
payment_order = frappe.get_doc(
doctype="Payment Order",
company="_Test Company",
payment_order_type=order_type,
company_bank_account=bank_account,
dict(
doctype="Payment Order",
company="_Test Company",
payment_order_type=order_type,
company_bank_account=bank_account,
)
)
doc = make_payment_order(ref_doc.name, payment_order)
doc.save()

View File

@@ -61,22 +61,6 @@ erpnext.accounts.PaymentReconciliationController = class PaymentReconciliationCo
},
};
});
this.frm.set_query("cost_center", "payments", () => {
return {
filters: {
company: this.frm.doc.company,
is_group: 0,
},
};
});
this.frm.set_query("cost_center", "allocation", () => {
return {
filters: {
company: this.frm.doc.company,
is_group: 0,
},
};
});
}
refresh() {
@@ -334,9 +318,7 @@ erpnext.accounts.PaymentReconciliationController = class PaymentReconciliationCo
},
{
fieldtype: "HTML",
options: __(
"New Journal Entry will be posted for the difference amount. The Posting Date can be modified."
).bold(),
options: "<b> New Journal Entry will be posted for the difference amount </b>",
},
],
primary_action: () => {

View File

@@ -6,7 +6,7 @@ import frappe
from frappe import _, msgprint, qb
from frappe.model.document import Document
from frappe.model.meta import get_field_precision
from frappe.query_builder import Case, Criterion
from frappe.query_builder import Criterion
from frappe.query_builder.custom import ConstantColumn
from frappe.utils import flt, fmt_money, get_link_to_form, getdate, nowdate, today
@@ -72,7 +72,7 @@ class PaymentReconciliation(Document):
self.common_filter_conditions = []
self.accounting_dimension_filter_conditions = []
self.ple_posting_date_filter = []
self.dimensions = get_dimensions(with_cost_center_and_project=True)[0]
self.dimensions = get_dimensions()[0]
def load_from_db(self):
# 'modified' attribute is required for `run_doc_method` to work properly.
@@ -393,9 +393,6 @@ class PaymentReconciliation(Document):
inv.outstanding_amount = flt(entry.get("outstanding_amount"))
def get_difference_amount(self, payment_entry, invoice, allocated_amount):
party_account_defaults = frappe.get_cached_value(
"Account", self.receivable_payable_account, ["account_type", "account_currency"], as_dict=True
)
allocated_amount_precision = get_field_precision(
frappe.get_meta("Payment Reconciliation Allocation").get_field("allocated_amount")
)
@@ -403,9 +400,9 @@ class PaymentReconciliation(Document):
frappe.get_meta("Payment Reconciliation Allocation").get_field("difference_amount")
)
difference_amount = 0
if party_account_defaults.get("account_currency") != frappe.get_cached_value(
"Company", self.company, "default_currency"
):
if frappe.get_cached_value(
"Account", self.receivable_payable_account, "account_currency"
) != frappe.get_cached_value("Company", self.company, "default_currency"):
if invoice.get("exchange_rate") and payment_entry.get("exchange_rate", 1) != invoice.get(
"exchange_rate", 1
):
@@ -417,14 +414,7 @@ class PaymentReconciliation(Document):
invoice.get("exchange_rate", 1) * flt(allocated_amount, allocated_amount_precision),
difference_amount_precision,
)
# Added If clause to handle return Adhoc payments for account type holders ("Payable")
if party_account_defaults.get("account_type") in ("Payable") and invoice.get(
"invoice_type"
) in ["Payment Entry", "Journal Entry"]:
difference_amount = allocated_amount_in_inv_rate - allocated_amount_in_ref_rate
else:
difference_amount = allocated_amount_in_ref_rate - allocated_amount_in_inv_rate
difference_amount = allocated_amount_in_ref_rate - allocated_amount_in_inv_rate
return difference_amount
@@ -679,7 +669,7 @@ class PaymentReconciliation(Document):
"party": self.party,
},
fields=[
"parent as name",
"parent as `name`",
"exchange_rate",
],
as_list=1,
@@ -687,28 +677,6 @@ class PaymentReconciliation(Document):
)
invoice_exchange_map.update(journals_map)
payment_entries = [
d.get("invoice_number") for d in invoices if d.get("invoice_type") == "Payment Entry"
]
payment_entries.extend(
[d.get("reference_name") for d in payments if d.get("reference_type") == "Payment Entry"]
)
if payment_entries:
pe = frappe.qb.DocType("Payment Entry")
query = (
frappe.qb.from_(pe)
.select(
pe.name,
Case()
.when(pe.payment_type == "Receive", pe.source_exchange_rate)
.else_(pe.target_exchange_rate)
.as_("exchange_rate"),
)
.where(pe.name.isin(payment_entries))
)
payment_entries = query.run(as_list=1)
invoice_exchange_map.update(payment_entries)
return invoice_exchange_map
def validate_allocation(self):
@@ -797,14 +765,6 @@ class PaymentReconciliation(Document):
def reconcile_dr_cr_note(dr_cr_notes, company, active_dimensions=None):
for inv in dr_cr_notes:
if (
abs(frappe.db.get_value(inv.voucher_type, inv.voucher_no, "outstanding_amount"))
< inv.allocated_amount
):
frappe.throw(
_("{0} has been modified after you pulled it. Please pull it again.").format(inv.voucher_type)
)
voucher_type = "Credit Note" if inv.voucher_type == "Sales Invoice" else "Debit Note"
reconcile_dr_or_cr = (

View File

@@ -975,7 +975,7 @@ class TestPaymentReconciliation(IntegrationTestCase):
total_credit_amount = frappe.db.get_all(
"Journal Entry Account",
{"account": self.debtors_eur, "docstatus": 1, "reference_name": si.name},
[{"SUM": "credit", "as": "amount"}],
"sum(credit) as amount",
group_by="reference_name",
)[0].amount
@@ -1069,7 +1069,7 @@ class TestPaymentReconciliation(IntegrationTestCase):
total_credit_amount = frappe.db.get_all(
"Journal Entry Account",
{"account": self.debtors_eur, "docstatus": 1, "reference_name": si.name},
[{"SUM": "credit", "as": "amount"}],
"sum(credit) as amount",
group_by="reference_name",
)[0].amount
@@ -2340,210 +2340,6 @@ class TestPaymentReconciliation(IntegrationTestCase):
frappe.db.set_value("Company", self.company, default_settings)
def test_foreign_currency_reverse_payment_entry_against_payment_entry_for_customer(self):
transaction_date = nowdate()
customer = self.customer3
amount = 1000
exchange_rate_at_payment = 100
exchange_rate_at_reverse_payment = 95
# Receive amount from customer - 1,00,000
pe = self.create_payment_entry(amount=amount, posting_date=transaction_date, customer=customer)
pe.payment_type = "Receive"
pe.paid_from = self.debtors_eur
pe.paid_from_account_currency = "EUR"
pe.source_exchange_rate = exchange_rate_at_payment
pe.paid_amount = amount
pe.received_amount = exchange_rate_at_payment * amount
pe.paid_to = self.cash
pe.paid_to_account_currency = "INR"
pe = pe.save().submit()
# Pay amount to customer - 95,000
reverse_pe = self.create_payment_entry(
amount=amount, posting_date=transaction_date, customer=customer
)
reverse_pe.payment_type = "Pay"
reverse_pe.paid_from = self.cash
reverse_pe.paid_from_account_currency = "INR"
reverse_pe.target_exchange_rate = exchange_rate_at_reverse_payment
reverse_pe.paid_amount = exchange_rate_at_reverse_payment * amount
reverse_pe.received_amount = amount
reverse_pe.paid_to = self.debtors_eur
reverse_pe.paid_to_account_currency = "EUR"
reverse_pe.save().submit()
# Reconcile payments
pr = self.create_payment_reconciliation()
pr.party = customer
pr.receivable_payable_account = self.debtors_eur
pr.get_unreconciled_entries()
invoices = [invoice.as_dict() for invoice in pr.invoices]
payments = [payment.as_dict() for payment in pr.payments]
self.assertEqual(len(pr.get("invoices")), 1)
self.assertEqual(len(pr.get("payments")), 1)
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
# Check the difference_amount is a gain of 5000
self.assertEqual(flt(pr.allocation[0].get("difference_amount")), 5000.0)
pr.reconcile()
def test_foreign_currency_reverse_payment_entry_against_payment_entry_for_supplier(self):
transaction_date = nowdate()
self.supplier = "_Test Supplier USD"
amount = 1000
exchange_rate_at_payment = 100
exchange_rate_at_reverse_payment = 95
# Pay amount to supplier - 1,00,000
pe = self.create_payment_entry(amount=amount, posting_date=transaction_date)
pe.payment_type = "Pay"
pe.party_type = "Supplier"
pe.party = self.supplier
pe.paid_from = self.cash
pe.paid_from_account_currency = "INR"
pe.target_exchange_rate = exchange_rate_at_payment
pe.paid_amount = exchange_rate_at_payment * amount
pe.received_amount = amount
pe.paid_to = self.creditors_usd
pe.paid_to_account_currency = "USD"
pe.save().submit()
# Receive amount from supplier - 95,000
reverse_pe = self.create_payment_entry(amount=amount, posting_date=transaction_date)
reverse_pe.payment_type = "Receive"
reverse_pe.party_type = "Supplier"
reverse_pe.party = self.supplier
reverse_pe.paid_from = self.creditors_usd
reverse_pe.paid_from_account_currency = "USD"
reverse_pe.source_exchange_rate = exchange_rate_at_reverse_payment
reverse_pe.paid_amount = amount
reverse_pe.received_amount = exchange_rate_at_reverse_payment * amount
reverse_pe.paid_to = self.cash
reverse_pe.paid_to_account_currency = "INR"
reverse_pe = reverse_pe.save().submit()
# Reconcile payments
pr = self.create_payment_reconciliation(party_is_customer=False)
pr.party = self.supplier
pr.receivable_payable_account = self.creditors_usd
pr.get_unreconciled_entries()
invoices = [invoice.as_dict() for invoice in pr.invoices]
payments = [payment.as_dict() for payment in pr.payments]
self.assertEqual(len(pr.get("invoices")), 1)
self.assertEqual(len(pr.get("payments")), 1)
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
# Check the difference_amount is a loss of 5000
self.assertEqual(flt(pr.allocation[0].get("difference_amount")), -5000.0)
pr.reconcile()
def test_foreign_currency_reverse_journal_entry_against_journal_entry_for_customer(self):
transaction_date = nowdate()
customer = self.customer3
amount = 1000
exchange_rate_at_payment = 95
exchange_rate_at_reverse_payment = 100
# Receive amount from customer - 95,000
je1 = self.create_journal_entry(self.cash, self.debtors_eur, amount, transaction_date)
je1.multi_currency = 1
je1.accounts[0].exchange_rate = 1
je1.accounts[0].debit_in_account_currency = exchange_rate_at_payment * amount
je1.accounts[0].debit = exchange_rate_at_payment * amount
je1.accounts[1].party_type = "Customer"
je1.accounts[1].party = customer
je1.accounts[1].exchange_rate = exchange_rate_at_payment
je1.accounts[1].credit_in_account_currency = amount
je1.accounts[1].credit = exchange_rate_at_payment * amount
je1.save()
je1.submit()
# Pay amount to customer - 1,00,000
je2 = self.create_journal_entry(self.debtors_eur, self.cash, amount, transaction_date)
je2.multi_currency = 1
je2.accounts[0].party_type = "Customer"
je2.accounts[0].party = customer
je2.accounts[0].exchange_rate = exchange_rate_at_reverse_payment
je2.accounts[0].debit_in_account_currency = amount
je2.accounts[0].debit = exchange_rate_at_reverse_payment * amount
je2.accounts[1].exchange_rate = 1
je2.accounts[1].credit_in_account_currency = exchange_rate_at_reverse_payment * amount
je2.accounts[1].credit = exchange_rate_at_reverse_payment * amount
je2.save()
je2.submit()
# Reconcile payments
pr = self.create_payment_reconciliation()
pr.party = customer
pr.receivable_payable_account = self.debtors_eur
pr.get_unreconciled_entries()
self.assertEqual(len(pr.invoices), 1)
self.assertEqual(len(pr.payments), 1)
invoices = [invoice.as_dict() for invoice in pr.invoices]
payments = [payment.as_dict() for payment in pr.payments]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
# Check the difference_amount is a loss of 5000
self.assertEqual(flt(pr.allocation[0].difference_amount), -5000.0)
pr.reconcile()
def test_foreign_currency_reverse_journal_entry_against_journal_entry_for_supplier(self):
transaction_date = nowdate()
self.supplier = "_Test Supplier USD"
amount = 1000
exchange_rate_at_payment = 95
exchange_rate_at_reverse_payment = 100
# Pay amount to supplier - 95,000
je1 = self.create_journal_entry(self.creditors_usd, self.cash, amount, transaction_date)
je1.multi_currency = 1
je1.accounts[0].party_type = "Supplier"
je1.accounts[0].party = self.supplier
je1.accounts[0].exchange_rate = exchange_rate_at_payment
je1.accounts[0].debit_in_account_currency = amount
je1.accounts[0].debit = exchange_rate_at_payment * amount
je1.accounts[1].exchange_rate = 1
je1.accounts[1].credit = exchange_rate_at_payment * amount
je1.accounts[1].credit_in_account_currency = exchange_rate_at_payment * amount
je1.save()
je1.submit()
# Receive amount from supplier - 1,00,000
je2 = self.create_journal_entry(self.cash, self.creditors_usd, amount, transaction_date)
je2.multi_currency = 1
je2.accounts[0].exchange_rate = 1
je2.accounts[0].debit = exchange_rate_at_reverse_payment * amount
je2.accounts[0].debit_in_account_currency = exchange_rate_at_reverse_payment * amount
je2.accounts[1].party_type = "Supplier"
je2.accounts[1].party = self.supplier
je2.accounts[1].exchange_rate = exchange_rate_at_reverse_payment
je2.accounts[1].credit_in_account_currency = amount
je2.accounts[1].credit = exchange_rate_at_reverse_payment * amount
je2.save()
je2.submit()
# Reconcile payments
pr = self.create_payment_reconciliation()
pr.party_type = "Supplier"
pr.party = self.supplier
pr.receivable_payable_account = self.creditors_usd
pr.get_unreconciled_entries()
self.assertEqual(len(pr.invoices), 1)
self.assertEqual(len(pr.payments), 1)
invoices = [invoice.as_dict() for invoice in pr.invoices]
payments = [payment.as_dict() for payment in pr.payments]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
# Check the difference_amount is a gain of 5000
self.assertEqual(flt(pr.allocation[0].difference_amount), 5000.0)
pr.reconcile()
def make_customer(customer_name, currency=None):
if not frappe.db.exists("Customer", customer_name):

View File

@@ -100,10 +100,7 @@ class PaymentRequest(Document):
subscription_plans: DF.Table[SubscriptionPlanDetail]
swift_number: DF.ReadOnly | None
transaction_date: DF.Date | None
# end: auto-generated types
def on_discard(self):
self.db_set("status", "Cancelled")
def validate(self):
if self.get("__islocal"):
@@ -430,7 +427,6 @@ class PaymentRequest(Document):
context = {
"doc": frappe.get_doc(self.reference_doctype, self.reference_name),
"payment_url": self.payment_url,
"payment_request": self,
}
if self.message:
@@ -543,9 +539,6 @@ def make_payment_request(**args):
if args.dt not in ALLOWED_DOCTYPES_FOR_PAYMENT_REQUEST:
frappe.throw(_("Payment Requests cannot be created against: {0}").format(frappe.bold(args.dt)))
if args.dn and not isinstance(args.dn, str):
frappe.throw(_("Invalid parameter. 'dn' should be of type str"))
ref_doc = args.ref_doc or frappe.get_doc(args.dt, args.dn)
if not args.get("company"):
args.company = ref_doc.company
@@ -850,7 +843,6 @@ def update_payment_requests_as_per_pe_references(references=None, cancel=False):
)
referenced_payment_requests = {pr.name: pr for pr in referenced_payment_requests}
doc_updates = {}
for ref in references:
if not ref.payment_request:
@@ -876,7 +868,7 @@ def update_payment_requests_as_per_pe_references(references=None, cancel=False):
title=_("Invalid Allocated Amount"),
)
# determine status
# update status
if new_outstanding_amount == payment_request["grand_total"]:
status = "Initiated" if payment_request["payment_request_type"] == "Outward" else "Requested"
elif new_outstanding_amount == 0:
@@ -884,37 +876,31 @@ def update_payment_requests_as_per_pe_references(references=None, cancel=False):
elif new_outstanding_amount > 0:
status = "Partially Paid"
# prepare bulk update data
doc_updates[ref.payment_request] = {
"outstanding_amount": new_outstanding_amount,
"status": status,
}
# bulk update all payment requests
if doc_updates:
frappe.db.bulk_update("Payment Request", doc_updates)
# update database
frappe.db.set_value(
"Payment Request",
ref.payment_request,
{"outstanding_amount": new_outstanding_amount, "status": status},
)
def get_dummy_message(doc):
return """
{% if doc.contact_person -%}
<p>Dear {{ doc.contact_person }},</p>
{%- else %}<p>Hello,</p>{% endif %}
return frappe.render_template(
"""{% if doc.contact_person -%}
<p>Dear {{ doc.contact_person }},</p>
{%- else %}<p>Hello,</p>{% endif %}
<p>
{{ _("Requesting payment against {0} {1} for amount {2}").format(
doc.doctype,
doc.name,
payment_request.get_formatted("grand_total")
) }}
</p>
<p>{{ _("Requesting payment against {0} {1} for amount {2}").format(doc.doctype,
doc.name, doc.get_formatted("grand_total")) }}</p>
<a href="{{ payment_url }}">{{ _("Make Payment") }}</a>
<a href="{{ payment_url }}">{{ _("Make Payment") }}</a>
<p>{{ _("If you have any questions, please get back to us.") }}</p>
<p>{{ _("If you have any questions, please get back to us.") }}</p>
<p>{{ _("Thank you for your business!") }}</p>
"""
<p>{{ _("Thank you for your business!") }}</p>
""",
dict(doc=doc, payment_url="{{ payment_url }}"),
)
@frappe.whitelist()

View File

@@ -4,8 +4,6 @@
frappe.ui.form.on("Period Closing Voucher", {
onload: function (frm) {
if (!frm.doc.transaction_date) frm.doc.transaction_date = frappe.datetime.obj_to_str(new Date());
frm.ignore_doctypes_on_cancel_all = ["Process Period Closing Voucher"];
},
setup: function (frm) {
@@ -13,9 +11,9 @@ frappe.ui.form.on("Period Closing Voucher", {
return {
filters: [
["Account", "company", "=", frm.doc.company],
["Account", "is_group", "=", 0],
["Account", "is_group", "=", "0"],
["Account", "freeze_account", "=", "No"],
["Account", "root_type", "in", ["Liability", "Equity"]],
["Account", "root_type", "in", "Liability, Equity"],
],
};
});

View File

@@ -132,11 +132,7 @@ class PeriodClosingVoucher(AccountsController):
def on_submit(self):
self.db_set("gle_processing_status", "In Progress")
if frappe.get_single_value("Accounts Settings", "use_legacy_controller_for_pcv"):
self.make_gl_entries()
else:
ppcv = frappe.get_doc({"doctype": "Process Period Closing Voucher", "parent_pcv": self.name})
ppcv.save().submit()
self.make_gl_entries()
def on_cancel(self):
self.ignore_linked_doctypes = (
@@ -144,29 +140,11 @@ class PeriodClosingVoucher(AccountsController):
"Stock Ledger Entry",
"Payment Ledger Entry",
"Account Closing Balance",
"Process Period Closing Voucher",
)
self.block_if_future_closing_voucher_exists()
if not frappe.get_single_value("Accounts Settings", "use_legacy_controller_for_pcv"):
self.cancel_process_pcv_docs()
self.db_set("gle_processing_status", "In Progress")
self.cancel_gl_entries()
def cancel_process_pcv_docs(self):
ppcvs = frappe.db.get_all("Process Period Closing Voucher", {"parent_pcv": self.name, "docstatus": 1})
for x in ppcvs:
frappe.get_doc("Process Period Closing Voucher", x.name).cancel()
def on_trash(self):
super().on_trash()
ppcvs = frappe.db.get_all(
"Process Period Closing Voucher", {"parent_pcv": self.name, "docstatus": ["in", [1, 2]]}
)
for x in ppcvs:
frappe.delete_doc("Process Period Closing Voucher", x.name, force=True, ignore_permissions=True)
def make_gl_entries(self):
if frappe.db.estimate_count("GL Entry") > 100_000:
frappe.enqueue(
@@ -475,15 +453,8 @@ def process_gl_and_closing_entries(doc):
frappe.db.set_value(doc.doctype, doc.name, "gle_processing_status", "Completed")
except Exception as e:
frappe.db.rollback()
frappe.log_error(title=_("Period Closing Voucher {0} GL Entry Processing Failed").format(doc.name))
frappe.db.set_value(
doc.doctype,
doc.name,
{
"error_message": str(e),
"gle_processing_status": "Failed",
},
)
frappe.log_error(e)
frappe.db.set_value(doc.doctype, doc.name, "gle_processing_status", "Failed")
def process_cancellation(voucher_type, voucher_no):
@@ -495,17 +466,8 @@ def process_cancellation(voucher_type, voucher_no):
frappe.db.set_value("Period Closing Voucher", voucher_no, "gle_processing_status", "Completed")
except Exception as e:
frappe.db.rollback()
frappe.log_error(
title=_("Period Closing Voucher {0} GL Entry Cancellation Failed").format(voucher_no)
)
frappe.db.set_value(
voucher_type,
voucher_no,
{
"error_message": str(e),
"gle_processing_status": "Failed",
},
)
frappe.log_error(e)
frappe.db.set_value("Period Closing Voucher", voucher_no, "gle_processing_status", "Failed")
def delete_closing_entries(voucher_no):

Some files were not shown because too many files have changed in this diff Show More