Merge branch 'develop' into version-16-beta

This commit is contained in:
Rohit Waghchaure
2025-12-23 21:40:14 +05:30
393 changed files with 105551 additions and 91084 deletions

View File

@@ -45,3 +45,9 @@ d827ed21adc7b36047e247cbb0dc6388d048a7f9
# `frappe.flags.in_test` => `frappe.in_test` # `frappe.flags.in_test` => `frappe.in_test`
7a482a69985c952de0e8193c9d4e086aee65ee6d 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 timeout-minutes: 60
steps: steps:
- name: Checkout Actions - name: Checkout Actions
uses: actions/checkout@v2 uses: actions/checkout@v6
with: with:
repository: "frappe/backport" repository: "frappe/backport"
path: ./actions path: ./actions

View File

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

View File

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

View File

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

View File

@@ -29,7 +29,7 @@ jobs:
services: services:
mysql: mysql:
image: mariadb:10.6 image: mariadb:11.8
env: env:
MARIADB_ROOT_PASSWORD: 'root' MARIADB_ROOT_PASSWORD: 'root'
ports: ports:
@@ -38,7 +38,7 @@ jobs:
steps: steps:
- name: Clone - name: Clone
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Check for valid Python & Merge Conflicts - name: Check for valid Python & Merge Conflicts
run: | run: |
@@ -49,14 +49,17 @@ jobs:
fi fi
- name: Setup Python - name: Setup Python
uses: actions/setup-python@v5 uses: actions/setup-python@v6
with: with:
python-version: '3.11' python-version: |
3.11
3.13
3.14
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v6
with: with:
node-version: 18 node-version: 24
check-latest: true check-latest: true
- name: Add to Hosts - name: Add to Hosts
@@ -132,15 +135,15 @@ jobs:
# Resetup env and install apps # Resetup env and install apps
pgrep honcho | xargs kill pgrep honcho | xargs kill
rm -rf ~/frappe-bench/env rm -rf ~/frappe-bench/env
bench -v setup env bench -v setup env --python python$2
bench pip install -e ./apps/erpnext bench pip install -e ./apps/erpnext
bench start &>> ~/frappe-bench/bench_start.log & bench start &>> ~/frappe-bench/bench_start.log &
bench --site test_site migrate bench --site test_site migrate
} }
update_to_version 14 update_to_version 14 3.11
update_to_version 15 update_to_version 15 3.13
echo "Updating to latest version" echo "Updating to latest version"
git -C "apps/frappe" fetch --depth 1 upstream "${GITHUB_BASE_REF:-${GITHUB_REF##*/}}" git -C "apps/frappe" fetch --depth 1 upstream "${GITHUB_BASE_REF:-${GITHUB_REF##*/}}"

View File

@@ -13,12 +13,12 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout Entire Repository - name: Checkout Entire Repository
uses: actions/checkout@v2 uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
persist-credentials: false persist-credentials: false
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v2 uses: actions/setup-node@v6
with: with:
node-version: 20 node-version: 20
- name: Setup dependencies - name: Setup dependencies

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ from frappe.utils import (
cint, cint,
date_diff, date_diff,
flt, flt,
formatdate,
get_first_day, get_first_day,
get_last_day, get_last_day,
get_link_to_form, get_link_to_form,
@@ -318,7 +319,7 @@ def get_already_booked_amount(doc, item):
def book_deferred_income_or_expense(doc, deferred_process, posting_date=None): def book_deferred_income_or_expense(doc, deferred_process, posting_date=None):
enable_check = "enable_deferred_revenue" if doc.doctype == "Sales Invoice" else "enable_deferred_expense" enable_check = "enable_deferred_revenue" if doc.doctype == "Sales Invoice" else "enable_deferred_expense"
accounts_frozen_upto = frappe.get_single_value("Accounts Settings", "acc_frozen_upto") accounts_frozen_upto = frappe.db.get_value("Company", doc.company, "accounts_frozen_till_date")
def _book_deferred_revenue_or_expense( def _book_deferred_revenue_or_expense(
item, item,

View File

@@ -93,8 +93,10 @@ class Account(NestedSet):
super().on_update() super().on_update()
def onload(self): def onload(self):
frozen_accounts_modifier = frappe.get_single_value("Accounts Settings", "frozen_accounts_modifier") role_allowed_for_frozen_entries = frappe.db.get_value(
if not frozen_accounts_modifier or frozen_accounts_modifier in frappe.get_roles(): "Company", self.company, "role_allowed_for_frozen_entries"
)
if not role_allowed_for_frozen_entries or role_allowed_for_frozen_entries in frappe.get_roles():
self.set_onload("can_freeze_account", True) self.set_onload("can_freeze_account", True)
def autoname(self): def autoname(self):
@@ -303,10 +305,10 @@ class Account(NestedSet):
if not doc_before_save or doc_before_save.freeze_account == self.freeze_account: if not doc_before_save or doc_before_save.freeze_account == self.freeze_account:
return return
frozen_accounts_modifier = frappe.get_cached_value( role_allowed_for_frozen_entries = frappe.get_cached_value(
"Accounts Settings", "Accounts Settings", "frozen_accounts_modifier" "Company", self.company, "role_allowed_for_frozen_entries"
) )
if not frozen_accounts_modifier or frozen_accounts_modifier not in frappe.get_roles(): if not role_allowed_for_frozen_entries or role_allowed_for_frozen_entries not in frappe.get_roles():
throw(_("You are not authorized to set Frozen value")) throw(_("You are not authorized to set Frozen value"))
def validate_balance_must_be_debit_or_credit(self): def validate_balance_must_be_debit_or_credit(self):

View File

@@ -70,6 +70,7 @@ frappe.treeview_settings["Account"] = {
args: { args: {
accounts: accounts, accounts: accounts,
company: cur_tree.args.company, company: cur_tree.args.company,
include_default_fb_balances: true,
}, },
}); });

View File

@@ -73,13 +73,12 @@
"calculate_depr_using_total_days", "calculate_depr_using_total_days",
"column_break_gjcc", "column_break_gjcc",
"book_asset_depreciation_entry_automatically", "book_asset_depreciation_entry_automatically",
"role_to_notify_on_depreciation_failure",
"closing_settings_tab", "closing_settings_tab",
"period_closing_settings_section", "period_closing_settings_section",
"acc_frozen_upto",
"ignore_account_closing_balance", "ignore_account_closing_balance",
"use_legacy_controller_for_pcv", "use_legacy_controller_for_pcv",
"column_break_25", "column_break_25",
"frozen_accounts_modifier",
"tab_break_dpet", "tab_break_dpet",
"show_balance_in_coa", "show_balance_in_coa",
"banking_tab", "banking_tab",
@@ -102,21 +101,6 @@
"use_legacy_budget_controller" "use_legacy_budget_controller"
], ],
"fields": [ "fields": [
{
"description": "Accounting entries are frozen up to this date. Nobody can create or modify entries except users with the role specified below",
"fieldname": "acc_frozen_upto",
"fieldtype": "Date",
"in_list_view": 1,
"label": "Accounts Frozen Till Date"
},
{
"description": "Users with this role are allowed to set frozen accounts and create / modify accounting entries against frozen accounts",
"fieldname": "frozen_accounts_modifier",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Role Allowed to Set Frozen Accounts and Edit Frozen Entries",
"options": "Role"
},
{ {
"default": "Billing Address", "default": "Billing Address",
"description": "Address used to determine Tax Category in transactions", "description": "Address used to determine Tax Category in transactions",
@@ -658,6 +642,13 @@
"fieldname": "use_legacy_controller_for_pcv", "fieldname": "use_legacy_controller_for_pcv",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Use Legacy Controller For Period Closing Voucher" "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"
} }
], ],
"grid_page_length": 50, "grid_page_length": 50,
@@ -666,7 +657,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2025-10-20 14:06:08.870427", "modified": "2025-12-03 20:42:13.238050",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Accounts Settings", "name": "Accounts Settings",

View File

@@ -11,7 +11,6 @@ from frappe.model.document import Document
from frappe.utils import cint from frappe.utils import cint
from erpnext.accounts.utils import sync_auto_reconcile_config from erpnext.accounts.utils import sync_auto_reconcile_config
from erpnext.stock.utils import check_pending_reposting
class AccountsSettings(Document): class AccountsSettings(Document):
@@ -23,7 +22,6 @@ class AccountsSettings(Document):
if TYPE_CHECKING: if TYPE_CHECKING:
from frappe.types import DF from frappe.types import DF
acc_frozen_upto: DF.Date | None
add_taxes_from_item_tax_template: DF.Check add_taxes_from_item_tax_template: DF.Check
add_taxes_from_taxes_and_charges_template: DF.Check add_taxes_from_taxes_and_charges_template: DF.Check
allow_multi_currency_invoices_against_single_party_account: DF.Check allow_multi_currency_invoices_against_single_party_account: DF.Check
@@ -50,7 +48,6 @@ class AccountsSettings(Document):
enable_party_matching: DF.Check enable_party_matching: DF.Check
exchange_gain_loss_posting_date: DF.Literal["Invoice", "Payment", "Reconciliation Date"] exchange_gain_loss_posting_date: DF.Literal["Invoice", "Payment", "Reconciliation Date"]
fetch_valuation_rate_for_internal_transaction: DF.Check fetch_valuation_rate_for_internal_transaction: DF.Check
frozen_accounts_modifier: DF.Link | None
general_ledger_remarks_length: DF.Int general_ledger_remarks_length: DF.Int
ignore_account_closing_balance: DF.Check ignore_account_closing_balance: DF.Check
ignore_is_opening_check_for_reporting: DF.Check ignore_is_opening_check_for_reporting: DF.Check
@@ -64,6 +61,7 @@ class AccountsSettings(Document):
receivable_payable_remarks_length: DF.Int receivable_payable_remarks_length: DF.Int
reconciliation_queue_size: DF.Int reconciliation_queue_size: DF.Int
role_allowed_to_over_bill: DF.Link | None 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 role_to_override_stop_action: DF.Link | None
round_row_wise_tax: DF.Check round_row_wise_tax: DF.Check
show_balance_in_coa: DF.Check show_balance_in_coa: DF.Check
@@ -100,9 +98,6 @@ class AccountsSettings(Document):
if old_doc.show_payment_schedule_in_print != self.show_payment_schedule_in_print: if old_doc.show_payment_schedule_in_print != self.show_payment_schedule_in_print:
self.enable_payment_schedule_in_print() self.enable_payment_schedule_in_print()
if old_doc.acc_frozen_upto != self.acc_frozen_upto:
self.validate_pending_reposts()
if clear_cache: if clear_cache:
frappe.clear_cache() frappe.clear_cache()
@@ -129,10 +124,6 @@ class AccountsSettings(Document):
validate_fields_for_doctype=False, validate_fields_for_doctype=False,
) )
def validate_pending_reposts(self):
if self.acc_frozen_upto:
check_pending_reposting(self.acc_frozen_upto)
def validate_and_sync_auto_reconcile_config(self): def validate_and_sync_auto_reconcile_config(self):
if self.has_value_changed("auto_reconciliation_job_trigger"): if self.has_value_changed("auto_reconciliation_job_trigger"):
if ( if (
@@ -161,6 +152,5 @@ class AccountsSettings(Document):
def drop_ar_sql_procedures(self): def drop_ar_sql_procedures(self):
from erpnext.accounts.report.accounts_receivable.accounts_receivable import InitSQLProceduresForAR 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.init_procedure_name}")
frappe.db.sql(f"drop procedure if exists {InitSQLProceduresForAR.allocate_procedure_name}") frappe.db.sql(f"drop procedure if exists {InitSQLProceduresForAR.allocate_procedure_name}")

View File

@@ -1,11 +1,16 @@
frappe.ui.form.on("Accounts Settings", { frappe.ui.form.on("Accounts Settings", {
refresh: function (frm) { refresh: function (frm) {
frm.set_df_property("acc_frozen_upto", "label", "Books Closed Through");
frm.set_df_property(
"frozen_accounts_modifier",
"label",
"Role Allowed to Close Books & Make Changes to Closed Periods"
);
frm.set_df_property("credit_controller", "label", "Credit Manager"); frm.set_df_property("credit_controller", "label", "Credit Manager");
}, },
}); });
frappe.ui.form.on("Company", {
refresh: function (frm) {
frm.set_df_property("accounts_frozen_till_date", "label", "Books Closed Through");
frm.set_df_property(
"role_allowed_for_frozen_entries",
"label",
"Role Allowed to Close Books & Make Changes to Closed Periods"
);
},
});

View File

@@ -1,57 +0,0 @@
{
"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,27 +0,0 @@
# 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 AdvanceTax(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_head: DF.Link | None
allocated_amount: DF.Currency
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
reference_detail: DF.Data | None
reference_name: DF.DynamicLink | None
reference_type: DF.Link | None
# end: auto-generated types
pass

View File

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

View File

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

View File

@@ -38,7 +38,10 @@
"column_break_3czf", "column_break_3czf",
"bank_party_name", "bank_party_name",
"bank_party_account_number", "bank_party_account_number",
"bank_party_iban" "bank_party_iban",
"extended_bank_statement_section",
"included_fee",
"excluded_fee"
], ],
"fields": [ "fields": [
{ {
@@ -233,12 +236,32 @@
{ {
"fieldname": "column_break_oufv", "fieldname": "column_break_oufv",
"fieldtype": "Column Break" "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, "grid_page_length": 50,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2025-10-23 17:32:58.514807", "modified": "2025-12-07 20:49:18.600757",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Bank Transaction", "name": "Bank Transaction",

View File

@@ -32,6 +32,8 @@ class BankTransaction(Document):
date: DF.Date | None date: DF.Date | None
deposit: DF.Currency deposit: DF.Currency
description: DF.SmallText | None description: DF.SmallText | None
excluded_fee: DF.Currency
included_fee: DF.Currency
naming_series: DF.Literal["ACC-BTN-.YYYY.-"] naming_series: DF.Literal["ACC-BTN-.YYYY.-"]
party: DF.DynamicLink | None party: DF.DynamicLink | None
party_type: DF.Link | None party_type: DF.Link | None
@@ -45,9 +47,11 @@ class BankTransaction(Document):
# end: auto-generated types # end: auto-generated types
def before_validate(self): def before_validate(self):
self.handle_excluded_fee()
self.update_allocated_amount() self.update_allocated_amount()
def validate(self): def validate(self):
self.validate_included_fee()
self.validate_duplicate_references() self.validate_duplicate_references()
self.validate_currency() self.validate_currency()
@@ -307,6 +311,40 @@ class BankTransaction(Document):
self.party_type, self.party = result 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() @frappe.whitelist()
def get_doctypes_for_bank_reconciliation(): def get_doctypes_for_bank_reconciliation():

View File

@@ -0,0 +1,133 @@
# 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

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

View File

@@ -78,7 +78,7 @@
"fieldname": "service_provider", "fieldname": "service_provider",
"fieldtype": "Select", "fieldtype": "Select",
"label": "Service Provider", "label": "Service Provider",
"options": "frankfurter.app\nexchangerate.host\nCustom", "options": "frankfurter.dev\nexchangerate.host\nCustom",
"reqd": 1 "reqd": 1
}, },
{ {
@@ -104,7 +104,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2024-03-27 13:06:47.653110", "modified": "2025-11-25 13:03:41.896424",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Currency Exchange Settings", "name": "Currency Exchange Settings",
@@ -141,8 +141,9 @@
"write": 1 "write": 1
} }
], ],
"row_format": "Dynamic",
"sort_field": "creation", "sort_field": "creation",
"sort_order": "DESC", "sort_order": "DESC",
"states": [], "states": [],
"track_changes": 1 "track_changes": 1
} }

View File

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

View File

@@ -100,7 +100,7 @@ class GLEntry(Document):
self.validate_account_details(adv_adj) self.validate_account_details(adv_adj)
self.validate_dimensions_for_pl_and_bs() self.validate_dimensions_for_pl_and_bs()
validate_balance_type(self.account, adv_adj) validate_balance_type(self.account, adv_adj)
validate_frozen_account(self.account, adv_adj) validate_frozen_account(self.company, self.account, adv_adj)
if ( if (
self.voucher_type == "Journal Entry" self.voucher_type == "Journal Entry"
@@ -276,7 +276,7 @@ class GLEntry(Document):
) )
def validate_party(self): def validate_party(self):
validate_party_frozen_disabled(self.party_type, self.party) validate_party_frozen_disabled(self.company, self.party_type, self.party)
validate_account_party_type(self) validate_account_party_type(self)
def validate_currency(self): def validate_currency(self):
@@ -419,16 +419,16 @@ def update_outstanding_amt(
ref_doc.set_status(update=True) ref_doc.set_status(update=True)
def validate_frozen_account(account, adv_adj=None): def validate_frozen_account(company, account, adv_adj=None):
frozen_account = frappe.get_cached_value("Account", account, "freeze_account") frozen_account = frappe.get_cached_value("Account", account, "freeze_account")
if frozen_account == "Yes" and not adv_adj: if frozen_account == "Yes" and not adv_adj:
frozen_accounts_modifier = frappe.get_cached_value( role_allowed_for_frozen_entries = frappe.get_cached_value(
"Accounts Settings", None, "frozen_accounts_modifier" "Company", company, "role_allowed_for_frozen_entries"
) )
if not frozen_accounts_modifier: if not role_allowed_for_frozen_entries:
frappe.throw(_("Account {0} is frozen").format(account)) frappe.throw(_("Account {0} is frozen").format(account))
elif frozen_accounts_modifier not in frappe.get_roles(): elif role_allowed_for_frozen_entries not in frappe.get_roles():
frappe.throw(_("Not authorized to edit frozen Account {0}").format(account)) frappe.throw(_("Not authorized to edit frozen Account {0}").format(account))

View File

@@ -201,6 +201,7 @@ frappe.ui.form.on("Journal Entry", {
erpnext.accounts.dimensions.update_dimension(frm, frm.doctype); erpnext.accounts.dimensions.update_dimension(frm, frm.doctype);
erpnext.utils.set_letter_head(frm); erpnext.utils.set_letter_head(frm);
frm.clear_table("tax_withholding_entries");
}, },
voucher_type: function (frm) { voucher_type: function (frm) {
@@ -251,6 +252,10 @@ frappe.ui.form.on("Journal Entry", {
}); });
} }
}, },
apply_tds: function (frm) {
frm.clear_table("tax_withholding_entries");
},
}); });
var update_jv_details = function (doc, r) { var update_jv_details = function (doc, r) {
@@ -720,6 +725,8 @@ $.extend(erpnext.journal_entry, {
} }
}, },
}); });
} else {
erpnext.journal_entry.clear_fields(frm, dt, dn);
} }
}, },
set_amount_on_last_row: function (frm, dt, dn) { set_amount_on_last_row: function (frm, dt, dn) {
@@ -744,4 +751,13 @@ $.extend(erpnext.journal_entry, {
} }
refresh_field("accounts"); 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,6 +43,11 @@
"total_amount_currency", "total_amount_currency",
"total_amount", "total_amount",
"total_amount_in_words", "total_amount_in_words",
"section_tax_withholding_entry",
"tax_withholding_group",
"ignore_tax_withholding_threshold",
"override_tax_withholding_entries",
"tax_withholding_entries",
"reference", "reference",
"clearance_date", "clearance_date",
"remark", "remark",
@@ -517,7 +522,7 @@
"depends_on": "eval:['Credit Note', 'Debit Note'].includes(doc.voucher_type)", "depends_on": "eval:['Credit Note', 'Debit Note'].includes(doc.voucher_type)",
"fieldname": "apply_tds", "fieldname": "apply_tds",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Apply Tax Withholding Amount " "label": "Consider for Tax Withholding "
}, },
{ {
"depends_on": "eval:doc.docstatus", "depends_on": "eval:doc.docstatus",
@@ -586,6 +591,39 @@
"hidden": 1, "hidden": 1,
"label": "Party Not Required", "label": "Party Not Required",
"no_copy": 1 "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", "icon": "fa fa-file-text",
@@ -600,7 +638,7 @@
"table_fieldname": "payment_entries" "table_fieldname": "payment_entries"
} }
], ],
"modified": "2025-09-29 13:05:46.982277", "modified": "2025-11-13 17:54:14.542903",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Journal Entry", "name": "Journal Entry",

View File

@@ -17,9 +17,7 @@ from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger
validate_docs_for_deferred_accounting, validate_docs_for_deferred_accounting,
validate_docs_for_voucher_types, validate_docs_for_voucher_types,
) )
from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category import ( from erpnext.accounts.doctype.tax_withholding_entry.tax_withholding_entry import JournalTaxWithholding
get_party_tax_withholding_details,
)
from erpnext.accounts.party import get_party_account from erpnext.accounts.party import get_party_account
from erpnext.accounts.utils import ( from erpnext.accounts.utils import (
cancel_exchange_gain_loss_journal, cancel_exchange_gain_loss_journal,
@@ -33,6 +31,7 @@ from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_sched
get_depr_schedule, get_depr_schedule,
) )
from erpnext.controllers.accounts_controller import AccountsController from erpnext.controllers.accounts_controller import AccountsController
from erpnext.setup.utils import get_exchange_rate as _get_exchange_rate
class StockAccountInvalidTransaction(frappe.ValidationError): class StockAccountInvalidTransaction(frappe.ValidationError):
@@ -49,6 +48,7 @@ class JournalEntry(AccountsController):
from frappe.types import DF from frappe.types import DF
from erpnext.accounts.doctype.journal_entry_account.journal_entry_account import JournalEntryAccount 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] accounts: DF.Table[JournalEntryAccount]
amended_from: DF.Link | None amended_from: DF.Link | None
@@ -65,6 +65,7 @@ class JournalEntry(AccountsController):
finance_book: DF.Link | None finance_book: DF.Link | None
for_all_stock_asset_accounts: DF.Check for_all_stock_asset_accounts: DF.Check
from_template: DF.Link | None from_template: DF.Link | None
ignore_tax_withholding_threshold: DF.Check
inter_company_journal_entry_reference: DF.Link | None inter_company_journal_entry_reference: DF.Link | None
is_opening: DF.Literal["No", "Yes"] is_opening: DF.Literal["No", "Yes"]
is_system_generated: DF.Check is_system_generated: DF.Check
@@ -73,6 +74,7 @@ class JournalEntry(AccountsController):
multi_currency: DF.Check multi_currency: DF.Check
naming_series: DF.Literal["ACC-JV-.YYYY.-"] naming_series: DF.Literal["ACC-JV-.YYYY.-"]
party_not_required: DF.Check party_not_required: DF.Check
override_tax_withholding_entries: DF.Check
pay_to_recd_from: DF.Data | None pay_to_recd_from: DF.Data | None
payment_order: DF.Link | None payment_order: DF.Link | None
periodic_entry_difference_account: DF.Link | None periodic_entry_difference_account: DF.Link | None
@@ -84,6 +86,8 @@ class JournalEntry(AccountsController):
stock_asset_account: DF.Link | None stock_asset_account: DF.Link | None
stock_entry: DF.Link | None stock_entry: DF.Link | None
tax_withholding_category: 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 title: DF.Data | None
total_amount: DF.Currency total_amount: DF.Currency
total_amount_currency: DF.Link | None total_amount_currency: DF.Link | None
@@ -150,8 +154,8 @@ class JournalEntry(AccountsController):
self.validate_company_in_accounting_dimension() self.validate_company_in_accounting_dimension()
self.validate_advance_accounts() self.validate_advance_accounts()
if self.docstatus == 0: JournalTaxWithholding(self).on_validate()
self.apply_tax_withholding()
if self.is_new() or not self.title: if self.is_new() or not self.title:
self.title = self.get_title() self.title = self.get_title()
@@ -199,6 +203,7 @@ class JournalEntry(AccountsController):
self.update_asset_value() self.update_asset_value()
self.update_inter_company_jv() self.update_inter_company_jv()
self.update_invoice_discounting() self.update_invoice_discounting()
JournalTaxWithholding(self).on_submit()
@frappe.whitelist() @frappe.whitelist()
def get_balance_for_periodic_accounting(self): def get_balance_for_periodic_accounting(self):
@@ -282,6 +287,8 @@ class JournalEntry(AccountsController):
self.repost_accounting_entries() self.repost_accounting_entries()
def on_cancel(self): def on_cancel(self):
# Cancel tax withholding entries
# References for this Journal are removed on the `on_cancel` event in accounts_controller # References for this Journal are removed on the `on_cancel` event in accounts_controller
super().on_cancel() super().on_cancel()
self.ignore_linked_doctypes = ( self.ignore_linked_doctypes = (
@@ -295,8 +302,10 @@ class JournalEntry(AccountsController):
"Unreconcile Payment", "Unreconcile Payment",
"Unreconcile Payment Entries", "Unreconcile Payment Entries",
"Advance Payment Ledger Entry", "Advance Payment Ledger Entry",
"Tax Withholding Entry",
) )
self.make_gl_entries(1) self.make_gl_entries(1)
JournalTaxWithholding(self).on_cancel()
self.unlink_advance_entry_reference() self.unlink_advance_entry_reference()
self.unlink_asset_reference() self.unlink_asset_reference()
self.unlink_inter_company_jv() self.unlink_inter_company_jv()
@@ -352,95 +361,6 @@ class JournalEntry(AccountsController):
StockAccountInvalidTransaction, 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): def update_asset_value(self):
self.update_asset_on_depreciation() self.update_asset_on_depreciation()
self.update_asset_on_disposal() self.update_asset_on_disposal()
@@ -1719,6 +1639,9 @@ def get_account_details_and_party_type(account, date, company, debit=None, credi
"party_type": party_type, "party_type": party_type,
"account_type": account_details.account_type, "account_type": account_details.account_type,
"account_currency": account_details.account_currency or company_currency, "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 # 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 # as an argument to this function. It is assumed to be the date on which the balance is sought
"exchange_rate": get_exchange_rate( "exchange_rate": get_exchange_rate(
@@ -1751,8 +1674,6 @@ def get_exchange_rate(
credit=None, credit=None,
exchange_rate=None, exchange_rate=None,
): ):
from erpnext.setup.utils import get_exchange_rate
account_details = frappe.get_cached_value( account_details = frappe.get_cached_value(
"Account", account, ["account_type", "root_type", "account_currency", "company"], as_dict=1 "Account", account, ["account_type", "root_type", "account_currency", "company"], as_dict=1
) )
@@ -1775,7 +1696,7 @@ def get_exchange_rate(
# The date used to retreive the exchange rate here is the date passed # The date used to retreive the exchange rate here is the date passed
# in as an argument to this function. # in as an argument to this function.
elif (not flt(exchange_rate) or flt(exchange_rate) == 1) and account_currency and posting_date: 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) exchange_rate = _get_exchange_rate(account_currency, company_currency, posting_date)
else: else:
exchange_rate = 1 exchange_rate = 1

View File

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

View File

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

View File

@@ -214,6 +214,9 @@ 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() accounting_dimension = get_accounting_dimensions()
for dimension in accounting_dimension: for dimension in accounting_dimension:
invoice.update({dimension: self.get(dimension) or item.get(dimension)}) invoice.update({dimension: self.get(dimension) or item.get(dimension)})

View File

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

View File

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

View File

@@ -41,6 +41,7 @@ frappe.ui.form.on("Payment Entry", {
if (frm.is_new()) { if (frm.is_new()) {
set_default_party_type(frm); set_default_party_type(frm);
frm.clear_table("tax_withholding_entries");
} }
}, },
@@ -532,6 +533,7 @@ frappe.ui.form.on("Payment Entry", {
}, },
() => frm.set_value("party_name", r.message.party_name), () => frm.set_value("party_name", r.message.party_name),
() => frm.clear_table("references"), () => frm.clear_table("references"),
() => frm.clear_table("tax_withholding_entries"),
() => frm.events.hide_unhide_fields(frm), () => frm.events.hide_unhide_fields(frm),
() => frm.events.set_dynamic_labels(frm), () => frm.events.set_dynamic_labels(frm),
() => { () => {
@@ -564,14 +566,15 @@ frappe.ui.form.on("Payment Entry", {
} }
}, },
apply_tax_withholding_amount: function (frm) { apply_tds: function (frm) {
if (!frm.doc.apply_tax_withholding_amount) { if (!frm.doc.apply_tds) {
frm.set_value("tax_withholding_category", ""); frm.set_value("tax_withholding_category", "");
} else { } else if (["Customer", "Supplier"].includes(frm.doc.party_type)) {
frappe.db.get_value("Supplier", frm.doc.party, "tax_withholding_category", (values) => { frappe.db.get_value(frm.doc.party_type, frm.doc.party, "tax_withholding_category", (values) => {
frm.set_value("tax_withholding_category", values.tax_withholding_category); frm.set_value("tax_withholding_category", values.tax_withholding_category);
}); });
} }
frm.clear_table("tax_withholding_entries");
}, },
paid_from: function (frm) { paid_from: function (frm) {
@@ -1277,15 +1280,14 @@ frappe.ui.form.on("Payment Entry", {
let row = (frm.doc.deductions || []).find((t) => t.is_exchange_gain_loss); let row = (frm.doc.deductions || []).find((t) => t.is_exchange_gain_loss);
if (!row) { if (!row) {
const response = await get_company_defaults(frm.doc.company); const company_defaults = frappe.get_doc(":Company", frm.doc.company);
const account = const account =
response.message?.[account_fieldname] || company_defaults?.[account_fieldname] ||
(await prompt_for_missing_account(frm, account_fieldname)); (await prompt_for_missing_account(frm, account_fieldname));
row = frm.add_child("deductions"); row = frm.add_child("deductions");
row.account = account; row.account = account;
row.cost_center = response.message?.cost_center; row.cost_center = company_defaults?.cost_center;
row.is_exchange_gain_loss = 1; row.is_exchange_gain_loss = 1;
} }
@@ -1495,18 +1497,14 @@ frappe.ui.form.on("Payment Entry", {
"Can refer row only if the charge type is 'On Previous Row Amount' or 'Previous Row Total'" "Can refer row only if the charge type is 'On Previous Row Amount' or 'Previous Row Total'"
); );
d.row_id = ""; d.row_id = "";
} else if ( } else if (d.charge_type == "On Previous Row Amount" || d.charge_type == "On Previous Row Total") {
(d.charge_type == "On Previous Row Amount" || d.charge_type == "On Previous Row Total") &&
d.row_id
) {
if (d.idx == 1) { if (d.idx == 1) {
msg = __( msg = __(
"Cannot select charge type as 'On Previous Row Amount' or 'On Previous Row Total' for first row" "Cannot select charge type as 'On Previous Row Amount' or 'On Previous Row Total' for first row"
); );
d.charge_type = ""; d.charge_type = "";
} else if (!d.row_id) { } else if (!d.row_id) {
msg = __("Please specify a valid Row ID for row {0} in table {1}", [d.idx, __(d.doctype)]); d.row_id = d.idx - 1;
d.row_id = "";
} else if (d.row_id && d.row_id >= d.idx) { } else if (d.row_id && d.row_id >= d.idx) {
msg = __( msg = __(
"Cannot refer row number greater than or equal to current row number for this Charge type" "Cannot refer row number greater than or equal to current row number for this Charge type"

View File

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

View File

@@ -30,9 +30,7 @@ from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger
validate_docs_for_deferred_accounting, validate_docs_for_deferred_accounting,
validate_docs_for_voucher_types, validate_docs_for_voucher_types,
) )
from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category import ( from erpnext.accounts.doctype.tax_withholding_entry.tax_withholding_entry import PaymentTaxWithholding
get_party_tax_withholding_details,
)
from erpnext.accounts.general_ledger import ( from erpnext.accounts.general_ledger import (
make_gl_entries, make_gl_entries,
make_reverse_gl_entries, make_reverse_gl_entries,
@@ -80,9 +78,10 @@ class PaymentEntry(AccountsController):
from erpnext.accounts.doctype.payment_entry_reference.payment_entry_reference import ( from erpnext.accounts.doctype.payment_entry_reference.payment_entry_reference import (
PaymentEntryReference, PaymentEntryReference,
) )
from erpnext.accounts.doctype.tax_withholding_entry.tax_withholding_entry import TaxWithholdingEntry
amended_from: DF.Link | None amended_from: DF.Link | None
apply_tax_withholding_amount: DF.Check apply_tds: DF.Check
auto_repeat: DF.Link | None auto_repeat: DF.Link | None
bank: DF.ReadOnly | None bank: DF.ReadOnly | None
bank_account: DF.Link | None bank_account: DF.Link | None
@@ -103,11 +102,13 @@ class PaymentEntry(AccountsController):
custom_remarks: DF.Check custom_remarks: DF.Check
deductions: DF.Table[PaymentEntryDeduction] deductions: DF.Table[PaymentEntryDeduction]
difference_amount: DF.Currency difference_amount: DF.Currency
ignore_tax_withholding_threshold: DF.Check
in_words: DF.SmallText | None in_words: DF.SmallText | None
is_opening: DF.Literal["No", "Yes"] is_opening: DF.Literal["No", "Yes"]
letter_head: DF.Link | None letter_head: DF.Link | None
mode_of_payment: DF.Link | None mode_of_payment: DF.Link | None
naming_series: DF.Literal["ACC-PAY-.YYYY.-"] naming_series: DF.Literal["ACC-PAY-.YYYY.-"]
override_tax_withholding_entries: DF.Check
paid_amount: DF.Currency paid_amount: DF.Currency
paid_amount_after_tax: DF.Currency paid_amount_after_tax: DF.Currency
paid_from: DF.Link paid_from: DF.Link
@@ -139,6 +140,8 @@ class PaymentEntry(AccountsController):
status: DF.Literal["", "Draft", "Submitted", "Cancelled"] status: DF.Literal["", "Draft", "Submitted", "Cancelled"]
target_exchange_rate: DF.Float target_exchange_rate: DF.Float
tax_withholding_category: DF.Link | None tax_withholding_category: DF.Link | None
tax_withholding_entries: DF.Table[TaxWithholdingEntry]
tax_withholding_group: DF.Link | None
taxes: DF.Table[AdvanceTaxesandCharges] taxes: DF.Table[AdvanceTaxesandCharges]
title: DF.Data | None title: DF.Data | None
total_allocated_amount: DF.Currency total_allocated_amount: DF.Currency
@@ -189,7 +192,7 @@ class PaymentEntry(AccountsController):
self.validate_allocated_amount() self.validate_allocated_amount()
self.validate_paid_invoices() self.validate_paid_invoices()
self.ensure_supplier_is_not_blocked() self.ensure_supplier_is_not_blocked()
self.set_tax_withholding() PaymentTaxWithholding(self).on_validate()
self.set_status() self.set_status()
self.set_total_in_words() self.set_total_in_words()
@@ -199,6 +202,7 @@ class PaymentEntry(AccountsController):
def on_submit(self): def on_submit(self):
if self.difference_amount: if self.difference_amount:
frappe.throw(_("Difference Amount must be zero")) frappe.throw(_("Difference Amount must be zero"))
PaymentTaxWithholding(self).on_submit()
self.update_payment_requests() self.update_payment_requests()
self.update_payment_schedule() self.update_payment_schedule()
self.make_gl_entries() self.make_gl_entries()
@@ -300,8 +304,10 @@ class PaymentEntry(AccountsController):
"Unreconcile Payment", "Unreconcile Payment",
"Unreconcile Payment Entries", "Unreconcile Payment Entries",
"Advance Payment Ledger Entry", "Advance Payment Ledger Entry",
"Tax Withholding Entry",
) )
super().on_cancel() super().on_cancel()
PaymentTaxWithholding(self).on_cancel()
self.update_payment_requests(cancel=True) self.update_payment_requests(cancel=True)
self.update_payment_schedule(cancel=1) self.update_payment_schedule(cancel=1)
self.make_gl_entries(cancel=1) self.make_gl_entries(cancel=1)
@@ -937,93 +943,6 @@ class PaymentEntry(AccountsController):
self.base_in_words = money_in_words(base_amount, self.company_currency) self.base_in_words = money_in_words(base_amount, self.company_currency)
self.in_words = money_in_words(amount, 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): def apply_taxes(self):
self.initialize_taxes() self.initialize_taxes()
self.determine_exclusive_rate() self.determine_exclusive_rate()
@@ -1874,7 +1793,7 @@ class PaymentEntry(AccountsController):
else: else:
self.total_taxes_and_charges += current_tax_amount self.total_taxes_and_charges += current_tax_amount
self.base_total_taxes_and_charges += tax.base_tax_amount self.base_total_taxes_and_charges += current_tax_amount
if self.get("taxes"): if self.get("taxes"):
self.paid_amount_after_tax = self.get("taxes")[-1].base_total self.paid_amount_after_tax = self.get("taxes")[-1].base_total

View File

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

View File

@@ -38,7 +38,7 @@
"search_index": 1 "search_index": 1
}, },
{ {
"columns": 2, "columns": 4,
"fieldname": "reference_name", "fieldname": "reference_name",
"fieldtype": "Dynamic Link", "fieldtype": "Dynamic Link",
"in_global_search": 1, "in_global_search": 1,
@@ -49,8 +49,10 @@
"search_index": 1 "search_index": 1
}, },
{ {
"columns": 2,
"fieldname": "due_date", "fieldname": "due_date",
"fieldtype": "Date", "fieldtype": "Date",
"in_list_view": 1,
"label": "Due Date", "label": "Due Date",
"read_only": 1 "read_only": 1
}, },
@@ -174,7 +176,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2025-07-25 04:32:11.040025", "modified": "2025-12-08 13:57:30.098239",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Payment Entry Reference", "name": "Payment Entry Reference",

View File

@@ -159,7 +159,7 @@ class PaymentLedgerEntry(Document):
def on_update(self): def on_update(self):
adv_adj = self.flags.adv_adj adv_adj = self.flags.adv_adj
if not self.flags.from_repost: if not self.flags.from_repost:
validate_frozen_account(self.account, adv_adj) validate_frozen_account(self.company, self.account, adv_adj)
if not self.delinked: if not self.delinked:
self.validate_account_details() self.validate_account_details()
self.validate_dimensions_for_pl_and_bs() self.validate_dimensions_for_pl_and_bs()

View File

@@ -334,7 +334,9 @@ erpnext.accounts.PaymentReconciliationController = class PaymentReconciliationCo
}, },
{ {
fieldtype: "HTML", fieldtype: "HTML",
options: "<b> New Journal Entry will be posted for the difference amount </b>", options: __(
"New Journal Entry will be posted for the difference amount. The Posting Date can be modified."
).bold(),
}, },
], ],
primary_action: () => { primary_action: () => {

View File

@@ -765,6 +765,14 @@ class PaymentReconciliation(Document):
def reconcile_dr_cr_note(dr_cr_notes, company, active_dimensions=None): def reconcile_dr_cr_note(dr_cr_notes, company, active_dimensions=None):
for inv in dr_cr_notes: 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" voucher_type = "Credit Note" if inv.voucher_type == "Sales Invoice" else "Debit Note"
reconcile_dr_or_cr = ( reconcile_dr_or_cr = (

View File

@@ -427,6 +427,7 @@ class PaymentRequest(Document):
context = { context = {
"doc": frappe.get_doc(self.reference_doctype, self.reference_name), "doc": frappe.get_doc(self.reference_doctype, self.reference_name),
"payment_url": self.payment_url, "payment_url": self.payment_url,
"payment_request": self,
} }
if self.message: if self.message:
@@ -539,6 +540,9 @@ def make_payment_request(**args):
if args.dt not in ALLOWED_DOCTYPES_FOR_PAYMENT_REQUEST: if args.dt not in ALLOWED_DOCTYPES_FOR_PAYMENT_REQUEST:
frappe.throw(_("Payment Requests cannot be created against: {0}").format(frappe.bold(args.dt))) 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) ref_doc = args.ref_doc or frappe.get_doc(args.dt, args.dn)
if not args.get("company"): if not args.get("company"):
args.company = ref_doc.company args.company = ref_doc.company
@@ -843,6 +847,7 @@ def update_payment_requests_as_per_pe_references(references=None, cancel=False):
) )
referenced_payment_requests = {pr.name: pr for pr in referenced_payment_requests} referenced_payment_requests = {pr.name: pr for pr in referenced_payment_requests}
doc_updates = {}
for ref in references: for ref in references:
if not ref.payment_request: if not ref.payment_request:
@@ -868,7 +873,7 @@ def update_payment_requests_as_per_pe_references(references=None, cancel=False):
title=_("Invalid Allocated Amount"), title=_("Invalid Allocated Amount"),
) )
# update status # determine status
if new_outstanding_amount == payment_request["grand_total"]: if new_outstanding_amount == payment_request["grand_total"]:
status = "Initiated" if payment_request["payment_request_type"] == "Outward" else "Requested" status = "Initiated" if payment_request["payment_request_type"] == "Outward" else "Requested"
elif new_outstanding_amount == 0: elif new_outstanding_amount == 0:
@@ -876,31 +881,37 @@ def update_payment_requests_as_per_pe_references(references=None, cancel=False):
elif new_outstanding_amount > 0: elif new_outstanding_amount > 0:
status = "Partially Paid" status = "Partially Paid"
# update database # prepare bulk update data
frappe.db.set_value( doc_updates[ref.payment_request] = {
"Payment Request", "outstanding_amount": new_outstanding_amount,
ref.payment_request, "status": status,
{"outstanding_amount": new_outstanding_amount, "status": status}, }
)
# bulk update all payment requests
if doc_updates:
frappe.db.bulk_update("Payment Request", doc_updates)
def get_dummy_message(doc): def get_dummy_message(doc):
return frappe.render_template( return """
"""{% if doc.contact_person -%} {% if doc.contact_person -%}
<p>Dear {{ doc.contact_person }},</p> <p>Dear {{ doc.contact_person }},</p>
{%- else %}<p>Hello,</p>{% endif %} {%- else %}<p>Hello,</p>{% endif %}
<p>{{ _("Requesting payment against {0} {1} for amount {2}").format(doc.doctype, <p>
doc.name, doc.get_formatted("grand_total")) }}</p> {{ _("Requesting payment against {0} {1} for amount {2}").format(
doc.doctype,
doc.name,
payment_request.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() @frappe.whitelist()

View File

@@ -20,6 +20,11 @@ from erpnext.accounts.party import get_due_date, get_party_account
from erpnext.controllers.queries import item_query as _item_query from erpnext.controllers.queries import item_query as _item_query
from erpnext.controllers.sales_and_purchase_return import get_sales_invoice_item_from_consolidated_invoice from erpnext.controllers.sales_and_purchase_return import get_sales_invoice_item_from_consolidated_invoice
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
from erpnext.stock.stock_ledger import is_negative_stock_allowed
class ProductBundleStockValidationError(frappe.ValidationError):
pass
class POSInvoice(SalesInvoice): class POSInvoice(SalesInvoice):
@@ -395,32 +400,67 @@ class POSInvoice(SalesInvoice):
for d in self.get("items"): for d in self.get("items"):
if not d.serial_and_batch_bundle: if not d.serial_and_batch_bundle:
available_stock, is_stock_item, is_negative_stock_allowed = get_stock_availability( if frappe.db.exists("Product Bundle", d.item_code):
d.item_code, d.warehouse (
) availability,
is_stock_item,
is_negative_stock_allowed,
) = get_product_bundle_stock_availability(d.item_code, d.warehouse, d.stock_qty)
else:
availability, is_stock_item, is_negative_stock_allowed = get_stock_availability(
d.item_code, d.warehouse
)
if is_negative_stock_allowed: if is_negative_stock_allowed:
continue continue
item_code, warehouse, _qty = ( if isinstance(availability, list):
frappe.bold(d.item_code), error_msgs = []
frappe.bold(d.warehouse), for item in availability:
frappe.bold(d.qty), if flt(item["available"]) < flt(item["required"]):
) error_msgs.append(
if is_stock_item and flt(available_stock) <= 0: _("<li>Packed Item {0}: Required {1}, Available {2}</li>").format(
frappe.throw( frappe.bold(item["item_code"]),
_("Row #{}: Item Code: {} is not available under warehouse {}.").format( frappe.bold(flt(item["required"], 2)),
d.idx, item_code, warehouse frappe.bold(flt(item["available"], 2)),
), )
title=_("Item Unavailable"), )
)
elif is_stock_item and flt(available_stock) < flt(d.stock_qty): if error_msgs:
frappe.throw( frappe.throw(
_("Row #{}: Stock quantity not enough for Item Code: {} under warehouse {}.").format( _(
d.idx, item_code, warehouse "<b>Row #{0}:</b> Bundle {1} in warehouse {2} has insufficient packed items:<br><div style='margin-top: 15px;'><ul style='line-height: 0.8;'>{3}</ul></div>"
), ).format(
title=_("Item Unavailable"), d.idx,
) frappe.bold(d.item_code),
frappe.bold(d.warehouse),
"<br>".join(error_msgs),
),
title=_("Insufficient Stock for Product Bundle Items"),
exc=ProductBundleStockValidationError,
)
else:
item_code, warehouse = frappe.bold(d.item_code), frappe.bold(d.warehouse)
if is_stock_item and flt(availability) <= 0:
frappe.throw(
_("Row #{0}: Item {1} has no stock in warehouse {2}.").format(
d.idx, item_code, warehouse
),
title=_("Item Out of Stock"),
)
elif is_stock_item and flt(availability) < flt(d.stock_qty):
frappe.throw(
_("Row #{0}: Item {1} in warehouse {2}: Available {3}, Needed {4}.").format(
d.idx,
item_code,
warehouse,
frappe.bold(flt(availability, 2)),
frappe.bold(flt(d.stock_qty, 2)),
),
title=_("Insufficient Stock"),
)
def validate_is_pos_using_sales_invoice(self): def validate_is_pos_using_sales_invoice(self):
self.invoice_type_in_pos = frappe.db.get_single_value("POS Settings", "invoice_type") self.invoice_type_in_pos = frappe.db.get_single_value("POS Settings", "invoice_type")
@@ -858,8 +898,6 @@ class POSInvoice(SalesInvoice):
@frappe.whitelist() @frappe.whitelist()
def get_stock_availability(item_code, warehouse): def get_stock_availability(item_code, warehouse):
from erpnext.stock.stock_ledger import is_negative_stock_allowed
if frappe.db.get_value("Item", item_code, "is_stock_item"): if frappe.db.get_value("Item", item_code, "is_stock_item"):
is_stock_item = True is_stock_item = True
bin_qty = get_bin_qty(item_code, warehouse) bin_qty = get_bin_qty(item_code, warehouse)
@@ -876,6 +914,26 @@ def get_stock_availability(item_code, warehouse):
return 0, is_stock_item, False return 0, is_stock_item, False
def get_product_bundle_stock_availability(item_code, warehouse, item_qty):
is_stock_item = True
bundle = frappe.get_doc("Product Bundle", item_code)
availabilities = []
for bundle_item in bundle.items:
if frappe.get_value("Item", bundle_item.item_code, "is_stock_item"):
bin_qty = get_bin_qty(bundle_item.item_code, warehouse)
reserved_qty = get_pos_reserved_qty(bundle_item.item_code, warehouse)
available = bin_qty - reserved_qty
availabilities.append(
{
"item_code": bundle_item.item_code,
"required": bundle_item.qty * item_qty,
"available": available,
}
)
return availabilities, is_stock_item, is_negative_stock_allowed(item_code=item_code)
def get_bundle_availability(bundle_item_code, warehouse): def get_bundle_availability(bundle_item_code, warehouse):
product_bundle = frappe.get_doc("Product Bundle", bundle_item_code) product_bundle = frappe.get_doc("Product Bundle", bundle_item_code)

View File

@@ -1024,6 +1024,84 @@ class TestPOSInvoice(IntegrationTestCase):
frappe.db.rollback(save_point="before_test_delivered_serial_no_case") frappe.db.rollback(save_point="before_test_delivered_serial_no_case")
frappe.set_user("Administrator") frappe.set_user("Administrator")
def test_bundle_stock_availability_validation(self):
from erpnext.accounts.doctype.pos_invoice.pos_invoice import ProductBundleStockValidationError
from erpnext.accounts.doctype.pos_invoice_merge_log.test_pos_invoice_merge_log import (
init_user_and_profile,
)
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
from erpnext.stock.doctype.item.test_item import create_item
init_user_and_profile()
frappe.set_user("Administrator")
warehouse = "_Test Warehouse - _TC"
company = "_Test Company"
# Create stock sub-items
sub_item_a = "_Test Bundle SubA"
if not frappe.db.exists("Item", sub_item_a):
create_item(
item_code=sub_item_a,
is_stock_item=1,
)
sub_item_b = "_Test Bundle SubB"
if not frappe.db.exists("Item", sub_item_b):
create_item(
item_code=sub_item_b,
is_stock_item=1,
)
# Add initial stock: SubA=5, SubB=2
make_stock_entry(item_code=sub_item_a, target=warehouse, qty=5, company=company)
make_stock_entry(item_code=sub_item_b, target=warehouse, qty=2, company=company)
# Create Product Bundle: Test Bundle (SubA x2 + SubB x1)
bundle_item = "_Test Bundle"
if not frappe.db.exists("Item", bundle_item):
create_item(
item_code=bundle_item,
is_stock_item=0,
)
if not frappe.db.exists("Product Bundle", bundle_item):
make_product_bundle(parent=bundle_item, items=[sub_item_a, sub_item_b])
# Test Case 1: Sufficient stock (bundle qty=1: requires SubA=2 (<=5), SubB=1 (<=2)) -> No error
pos_inv_sufficient = create_pos_invoice(
item=bundle_item,
qty=1,
rate=100,
warehouse=warehouse,
pos_profile=self.pos_profile.name,
do_not_save=1,
)
pos_inv_sufficient.append("payments", {"mode_of_payment": "Cash", "amount": 100, "default": 1})
pos_inv_sufficient.insert()
pos_inv_sufficient.submit()
pos_inv_sufficient.cancel()
pos_inv_sufficient.delete()
# Test Case 2: Insufficient stock (reduce SubB to 1, bundle qty=2: requires SubB=2 >1) -> Error with details
make_stock_entry(item_code=sub_item_b, from_warehouse=warehouse, qty=1, company=company)
pos_inv_insufficient = create_pos_invoice(
item=bundle_item,
qty=2,
rate=100,
warehouse=warehouse,
pos_profile=self.pos_profile.name,
do_not_save=1,
)
pos_inv_insufficient.append("payments", {"mode_of_payment": "Cash", "amount": 200, "default": 1})
pos_inv_insufficient.save()
self.assertRaises(ProductBundleStockValidationError, pos_inv_insufficient.submit)
frappe.set_user("test@example.com")
def create_pos_invoice(**args): def create_pos_invoice(**args):
args = frappe._dict(args) args = frappe._dict(args)

View File

@@ -244,7 +244,7 @@ def get_other_conditions(conditions, values, args):
conditions += " and " + group_condition conditions += " and " + group_condition
date = args.get("transaction_date") or frappe.get_value( date = args.get("transaction_date") or frappe.get_value(
args.get("doctype"), args.get("name"), "posting_date" args.get("doctype"), args.get("name"), "posting_date", ignore=True
) )
if date: if date:
conditions += """ and %(transaction_date)s between ifnull(`tabPricing Rule`.valid_from, '2000-01-01') conditions += """ and %(transaction_date)s between ifnull(`tabPricing Rule`.valid_from, '2000-01-01')

View File

@@ -16,7 +16,7 @@ from erpnext.stock.doctype.item.test_item import create_item
class TestProcessDeferredAccounting(IntegrationTestCase): class TestProcessDeferredAccounting(IntegrationTestCase):
def test_creation_of_ledger_entry_on_submit(self): def test_creation_of_ledger_entry_on_submit(self):
"""test creation of gl entries on submission of document""" """test creation of gl entries on submission of document"""
change_acc_settings(acc_frozen_upto="2023-05-31", book_deferred_entries_based_on="Months") change_acc_settings(acc_frozen_till_date="2023-05-31", book_deferred_entries_based_on="Months")
deferred_account = create_account( deferred_account = create_account(
account_name="Deferred Revenue for Accounts Frozen", account_name="Deferred Revenue for Accounts Frozen",
@@ -92,8 +92,10 @@ class TestProcessDeferredAccounting(IntegrationTestCase):
pda.cancel() pda.cancel()
def change_acc_settings(acc_frozen_upto="", book_deferred_entries_based_on="Days"): def change_acc_settings(
company="_Test Company", acc_frozen_till_date=None, book_deferred_entries_based_on="Days"
):
acc_settings = frappe.get_doc("Accounts Settings", "Accounts Settings") acc_settings = frappe.get_doc("Accounts Settings", "Accounts Settings")
acc_settings.acc_frozen_upto = acc_frozen_upto
acc_settings.book_deferred_entries_based_on = book_deferred_entries_based_on acc_settings.book_deferred_entries_based_on = book_deferred_entries_based_on
frappe.db.set_value("Company", company, "accounts_frozen_till_date", acc_frozen_till_date)
acc_settings.save() acc_settings.save()

View File

@@ -12,6 +12,7 @@ erpnext.buying.setup_buying_controller();
erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.BuyingController { erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.BuyingController {
setup(doc) { setup(doc) {
this.setup_accounting_dimension_triggers();
this.setup_posting_date_time_check(); this.setup_posting_date_time_check();
super.setup(doc); super.setup(doc);
@@ -129,7 +130,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
} }
} }
if (doc.outstanding_amount > 0 && !cint(doc.is_return) && !doc.on_hold) { if (doc.docstatus == 1 && doc.outstanding_amount > 0 && !cint(doc.is_return) && !doc.on_hold) {
this.frm.add_custom_button( this.frm.add_custom_button(
__("Payment Request"), __("Payment Request"),
function () { function () {
@@ -223,7 +224,6 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
}); });
} }
this.frm.set_df_property("tax_withholding_category", "hidden", doc.apply_tds ? 0 : 1);
erpnext.accounts.unreconcile_payment.add_unreconcile_btn(me.frm); erpnext.accounts.unreconcile_payment.add_unreconcile_btn(me.frm);
} }
@@ -363,10 +363,9 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
}, },
function () { function () {
me.apply_pricing_rule(); me.apply_pricing_rule();
me.frm.doc.apply_tds = me.frm.supplier_tds ? 1 : 0; me.frm.doc.apply_tds =
me.frm.doc.tax_withholding_category = me.frm.supplier_tds; me.frm.tax_withholding_category || me.frm.tax_withholding_group ? 1 : 0;
me.frm.set_df_property("apply_tds", "read_only", me.frm.supplier_tds ? 0 : 1); me.frm.clear_table("tax_withholding_entries");
me.frm.set_df_property("tax_withholding_category", "hidden", me.frm.supplier_tds ? 0 : 1);
// while duplicating, don't change payment terms // while duplicating, don't change payment terms
if (me.frm.doc.__run_link_triggers === false) { if (me.frm.doc.__run_link_triggers === false) {
@@ -379,26 +378,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
apply_tds(frm) { apply_tds(frm) {
var me = this; var me = this;
me.frm.set_value("tax_withheld_vouchers", []); me.frm.clear_table("tax_withholding_entries");
if (!me.frm.doc.apply_tds) {
me.frm.set_value("tax_withholding_category", "");
me.frm.set_df_property("tax_withholding_category", "hidden", 1);
} else {
me.frm.set_value("tax_withholding_category", me.frm.supplier_tds);
me.frm.set_df_property("tax_withholding_category", "hidden", 0);
}
}
tax_withholding_category(frm) {
var me = this;
let filtered_taxes = (me.frm.doc.taxes || []).filter((row) => !row.is_tax_withholding_account);
me.frm.clear_table("taxes");
filtered_taxes.forEach((row) => {
me.frm.add_child("taxes", row);
});
me.frm.refresh_field("taxes");
} }
credit_to() { credit_to() {
@@ -578,17 +558,6 @@ cur_frm.fields_dict["items"].grid.get_field("cost_center").get_query = function
}; };
}; };
cur_frm.cscript.cost_center = function (doc, cdt, cdn) {
var d = locals[cdt][cdn];
if (d.cost_center) {
var cl = doc.items || [];
for (var i = 0; i < cl.length; i++) {
if (!cl[i].cost_center) cl[i].cost_center = d.cost_center;
}
}
refresh_field("items");
};
cur_frm.fields_dict["items"].grid.get_field("project").get_query = function (doc, cdt, cdn) { cur_frm.fields_dict["items"].grid.get_field("project").get_query = function (doc, cdt, cdn) {
return { return {
filters: [["Project", "status", "not in", "Completed, Cancelled"]], filters: [["Project", "status", "not in", "Completed, Cancelled"]],
@@ -702,10 +671,7 @@ frappe.ui.form.on("Purchase Invoice", {
onload: function (frm) { onload: function (frm) {
if (frm.doc.__onload && frm.doc.supplier) { if (frm.doc.__onload && frm.doc.supplier) {
if (frm.is_new()) { if (frm.is_new()) {
frm.doc.apply_tds = frm.doc.__onload.supplier_tds ? 1 : 0; frm.doc.apply_tds = frm.doc.__onload.apply_tds ? 1 : 0;
}
if (!frm.doc.__onload.supplier_tds) {
frm.set_df_property("apply_tds", "read_only", 1);
} }
} }
@@ -714,7 +680,7 @@ frappe.ui.form.on("Purchase Invoice", {
}); });
if (frm.is_new()) { if (frm.is_new()) {
frm.clear_table("tax_withheld_vouchers"); frm.clear_table("tax_withholding_entries");
} }
}, },
@@ -741,6 +707,7 @@ frappe.ui.form.on("Purchase Invoice", {
company: function (frm) { company: function (frm) {
erpnext.accounts.dimensions.update_dimension(frm, frm.doctype); erpnext.accounts.dimensions.update_dimension(frm, frm.doctype);
frm.clear_table("tax_withholding_entries");
if (frm.doc.company) { if (frm.doc.company) {
frappe.call({ frappe.call({

View File

@@ -27,7 +27,6 @@
"update_billed_amount_in_purchase_order", "update_billed_amount_in_purchase_order",
"update_billed_amount_in_purchase_receipt", "update_billed_amount_in_purchase_receipt",
"apply_tds", "apply_tds",
"tax_withholding_category",
"amended_from", "amended_from",
"supplier_invoice_details", "supplier_invoice_details",
"bill_no", "bill_no",
@@ -68,8 +67,6 @@
"column_break_28", "column_break_28",
"total", "total",
"net_total", "net_total",
"tax_withholding_net_total",
"base_tax_withholding_net_total",
"taxes_section", "taxes_section",
"tax_category", "tax_category",
"taxes_and_charges", "taxes_and_charges",
@@ -102,14 +99,17 @@
"total_advance", "total_advance",
"outstanding_amount", "outstanding_amount",
"disable_rounded_total", "disable_rounded_total",
"section_tax_withholding_entry",
"tax_withholding_group",
"ignore_tax_withholding_threshold",
"override_tax_withholding_entries",
"tax_withholding_entries",
"section_break_44", "section_break_44",
"apply_discount_on", "apply_discount_on",
"base_discount_amount", "base_discount_amount",
"column_break_46", "column_break_46",
"additional_discount_percentage", "additional_discount_percentage",
"discount_amount", "discount_amount",
"tax_withheld_vouchers_section",
"tax_withheld_vouchers",
"sec_tax_breakup", "sec_tax_breakup",
"other_charges_calculation", "other_charges_calculation",
"item_wise_tax_details", "item_wise_tax_details",
@@ -130,7 +130,6 @@
"only_include_allocated_payments", "only_include_allocated_payments",
"get_advances", "get_advances",
"advances", "advances",
"advance_tax",
"write_off", "write_off",
"write_off_amount", "write_off_amount",
"base_write_off_amount", "base_write_off_amount",
@@ -286,7 +285,7 @@
"default": "0", "default": "0",
"fieldname": "apply_tds", "fieldname": "apply_tds",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Apply Tax Withholding Amount", "label": "Consider for Tax Withholding",
"print_hide": 1 "print_hide": 1
}, },
{ {
@@ -1358,14 +1357,6 @@
"fieldname": "dimension_col_break", "fieldname": "dimension_col_break",
"fieldtype": "Column Break" "fieldtype": "Column Break"
}, },
{
"fieldname": "tax_withholding_category",
"fieldtype": "Link",
"hidden": 1,
"label": "Tax Withholding Category",
"options": "Tax Withholding Category",
"print_hide": 1
},
{ {
"fieldname": "billing_address", "fieldname": "billing_address",
"fieldtype": "Link", "fieldtype": "Link",
@@ -1455,14 +1446,6 @@
"fieldname": "column_break_147", "fieldname": "column_break_147",
"fieldtype": "Column Break" "fieldtype": "Column Break"
}, },
{
"fieldname": "advance_tax",
"fieldtype": "Table",
"hidden": 1,
"label": "Advance Tax",
"options": "Advance Tax",
"read_only": 1
},
{ {
"fieldname": "subscription", "fieldname": "subscription",
"fieldtype": "Link", "fieldtype": "Link",
@@ -1477,42 +1460,6 @@
"label": "Is Old Subcontracting Flow", "label": "Is Old Subcontracting Flow",
"read_only": 1 "read_only": 1
}, },
{
"default": "0",
"depends_on": "apply_tds",
"fieldname": "tax_withholding_net_total",
"fieldtype": "Currency",
"hidden": 1,
"label": "Tax Withholding Net Total",
"no_copy": 1,
"options": "currency",
"read_only": 1
},
{
"depends_on": "apply_tds",
"fieldname": "base_tax_withholding_net_total",
"fieldtype": "Currency",
"hidden": 1,
"label": "Base Tax Withholding Net Total",
"no_copy": 1,
"options": "Company:company:default_currency",
"print_hide": 1,
"read_only": 1
},
{
"collapsible_depends_on": "tax_withheld_vouchers",
"fieldname": "tax_withheld_vouchers_section",
"fieldtype": "Section Break",
"label": "Tax Withheld Vouchers"
},
{
"fieldname": "tax_withheld_vouchers",
"fieldtype": "Table",
"label": "Tax Withheld Vouchers",
"no_copy": 1,
"options": "Tax Withheld Vouchers",
"read_only": 1
},
{ {
"fieldname": "payments_tab", "fieldname": "payments_tab",
"fieldtype": "Tab Break", "fieldtype": "Tab Break",
@@ -1662,7 +1609,7 @@
"fieldtype": "Data", "fieldtype": "Data",
"is_virtual": 1, "is_virtual": 1,
"label": "Last Scanned Warehouse" "label": "Last Scanned Warehouse"
}, },
{ {
"fieldname": "claimed_landed_cost_amount", "fieldname": "claimed_landed_cost_amount",
"fieldtype": "Currency", "fieldtype": "Currency",
@@ -1679,6 +1626,40 @@
"label": "Item Wise Tax Details", "label": "Item Wise Tax Details",
"no_copy": 1, "no_copy": 1,
"options": "Item Wise Tax Detail" "options": "Item Wise Tax Detail"
},
{
"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",
"print_hide": 1
},
{
"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": "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"
} }
], ],
"grid_page_length": 50, "grid_page_length": 50,
@@ -1686,7 +1667,7 @@
"idx": 204, "idx": 204,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2025-08-04 19:19:11.380664", "modified": "2025-12-15 06:41:38.237728",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Purchase Invoice", "name": "Purchase Invoice",

View File

@@ -24,9 +24,7 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
update_linked_doc, update_linked_doc,
validate_inter_company_party, validate_inter_company_party,
) )
from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category import ( from erpnext.accounts.doctype.tax_withholding_entry.tax_withholding_entry import PurchaseTaxWithholding
get_party_tax_withholding_details,
)
from erpnext.accounts.general_ledger import ( from erpnext.accounts.general_ledger import (
get_round_off_account_and_cost_center, get_round_off_account_and_cost_center,
make_gl_entries, make_gl_entries,
@@ -61,7 +59,6 @@ class PurchaseInvoice(BuyingController):
if TYPE_CHECKING: if TYPE_CHECKING:
from frappe.types import DF from frappe.types import DF
from erpnext.accounts.doctype.advance_tax.advance_tax import AdvanceTax
from erpnext.accounts.doctype.item_wise_tax_detail.item_wise_tax_detail import ItemWiseTaxDetail from erpnext.accounts.doctype.item_wise_tax_detail.item_wise_tax_detail import ItemWiseTaxDetail
from erpnext.accounts.doctype.payment_schedule.payment_schedule import PaymentSchedule from erpnext.accounts.doctype.payment_schedule.payment_schedule import PaymentSchedule
from erpnext.accounts.doctype.pricing_rule_detail.pricing_rule_detail import PricingRuleDetail from erpnext.accounts.doctype.pricing_rule_detail.pricing_rule_detail import PricingRuleDetail
@@ -72,14 +69,13 @@ class PurchaseInvoice(BuyingController):
from erpnext.accounts.doctype.purchase_taxes_and_charges.purchase_taxes_and_charges import ( from erpnext.accounts.doctype.purchase_taxes_and_charges.purchase_taxes_and_charges import (
PurchaseTaxesandCharges, PurchaseTaxesandCharges,
) )
from erpnext.accounts.doctype.tax_withheld_vouchers.tax_withheld_vouchers import TaxWithheldVouchers from erpnext.accounts.doctype.tax_withholding_entry.tax_withholding_entry import TaxWithholdingEntry
from erpnext.buying.doctype.purchase_receipt_item_supplied.purchase_receipt_item_supplied import ( from erpnext.buying.doctype.purchase_receipt_item_supplied.purchase_receipt_item_supplied import (
PurchaseReceiptItemSupplied, PurchaseReceiptItemSupplied,
) )
additional_discount_percentage: DF.Float additional_discount_percentage: DF.Float
address_display: DF.TextEditor | None address_display: DF.TextEditor | None
advance_tax: DF.Table[AdvanceTax]
advances: DF.Table[PurchaseInvoiceAdvance] advances: DF.Table[PurchaseInvoiceAdvance]
against_expense_account: DF.SmallText | None against_expense_account: DF.SmallText | None
allocate_advances_automatically: DF.Check allocate_advances_automatically: DF.Check
@@ -94,7 +90,6 @@ class PurchaseInvoice(BuyingController):
base_paid_amount: DF.Currency base_paid_amount: DF.Currency
base_rounded_total: DF.Currency base_rounded_total: DF.Currency
base_rounding_adjustment: DF.Currency base_rounding_adjustment: DF.Currency
base_tax_withholding_net_total: DF.Currency
base_taxes_and_charges_added: DF.Currency base_taxes_and_charges_added: DF.Currency
base_taxes_and_charges_deducted: DF.Currency base_taxes_and_charges_deducted: DF.Currency
base_total: DF.Currency base_total: DF.Currency
@@ -128,6 +123,7 @@ class PurchaseInvoice(BuyingController):
hold_comment: DF.SmallText | None hold_comment: DF.SmallText | None
ignore_default_payment_terms_template: DF.Check ignore_default_payment_terms_template: DF.Check
ignore_pricing_rule: DF.Check ignore_pricing_rule: DF.Check
ignore_tax_withholding_threshold: DF.Check
in_words: DF.Data | None in_words: DF.Data | None
incoterm: DF.Link | None incoterm: DF.Link | None
inter_company_invoice_reference: DF.Link | None inter_company_invoice_reference: DF.Link | None
@@ -149,6 +145,7 @@ class PurchaseInvoice(BuyingController):
only_include_allocated_payments: DF.Check only_include_allocated_payments: DF.Check
other_charges_calculation: DF.TextEditor | None other_charges_calculation: DF.TextEditor | None
outstanding_amount: DF.Currency outstanding_amount: DF.Currency
override_tax_withholding_entries: DF.Check
paid_amount: DF.Currency paid_amount: DF.Currency
party_account_currency: DF.Link | None party_account_currency: DF.Link | None
payment_schedule: DF.Table[PaymentSchedule] payment_schedule: DF.Table[PaymentSchedule]
@@ -198,9 +195,8 @@ class PurchaseInvoice(BuyingController):
supplier_warehouse: DF.Link | None supplier_warehouse: DF.Link | None
tax_category: DF.Link | None tax_category: DF.Link | None
tax_id: DF.ReadOnly | None tax_id: DF.ReadOnly | None
tax_withheld_vouchers: DF.Table[TaxWithheldVouchers] tax_withholding_entries: DF.Table[TaxWithholdingEntry]
tax_withholding_category: DF.Link | None tax_withholding_group: DF.Link | None
tax_withholding_net_total: DF.Currency
taxes: DF.Table[PurchaseTaxesandCharges] taxes: DF.Table[PurchaseTaxesandCharges]
taxes_and_charges: DF.Link | None taxes_and_charges: DF.Link | None
taxes_and_charges_added: DF.Currency taxes_and_charges_added: DF.Currency
@@ -245,11 +241,14 @@ class PurchaseInvoice(BuyingController):
def onload(self): def onload(self):
super().onload() super().onload()
supplier_tds = frappe.db.get_value("Supplier", self.supplier, "tax_withholding_category") if self.supplier:
self.set_onload("supplier_tds", supplier_tds) tax_withholding_category, tax_withholding_group = frappe.get_cached_value(
"Supplier", self.supplier, ["tax_withholding_category", "tax_withholding_group"]
)
self.set_onload("apply_tds", tax_withholding_category or tax_withholding_group)
if self.is_new(): if self.is_new():
self.set("tax_withheld_vouchers", []) self.set("tax_withholding_entries", [])
def before_save(self): def before_save(self):
if not self.on_hold: if not self.on_hold:
@@ -300,6 +299,7 @@ class PurchaseInvoice(BuyingController):
self.reset_default_field_value("set_warehouse", "items", "warehouse") self.reset_default_field_value("set_warehouse", "items", "warehouse")
self.reset_default_field_value("rejected_warehouse", "items", "rejected_warehouse") self.reset_default_field_value("rejected_warehouse", "items", "rejected_warehouse")
self.reset_default_field_value("set_from_warehouse", "items", "from_warehouse") self.reset_default_field_value("set_from_warehouse", "items", "from_warehouse")
PurchaseTaxWithholding(self).on_validate()
self.set_percentage_received() self.set_percentage_received()
def set_percentage_received(self): def set_percentage_received(self):
@@ -352,11 +352,13 @@ class PurchaseInvoice(BuyingController):
template_name=self.payment_terms_template, template_name=self.payment_terms_template,
) )
tds_category = frappe.db.get_value("Supplier", self.supplier, "tax_withholding_category") if self.supplier:
if tds_category and not for_validate: tax_withholding_category, tax_withholding_group = frappe.get_cached_value(
self.apply_tds = 1 "Supplier", self.supplier, ["tax_withholding_category", "tax_withholding_group"]
self.tax_withholding_category = tds_category )
self.set_onload("supplier_tds", tds_category) if not for_validate:
if tax_withholding_category or tax_withholding_group:
self.apply_tds = 1
super().set_missing_values(for_validate) super().set_missing_values(for_validate)
@@ -747,6 +749,7 @@ class PurchaseInvoice(BuyingController):
def on_submit(self): def on_submit(self):
super().on_submit() super().on_submit()
PurchaseTaxWithholding(self).on_submit()
self.check_prev_docstatus() self.check_prev_docstatus()
@@ -788,7 +791,6 @@ class PurchaseInvoice(BuyingController):
self.update_project() self.update_project()
update_linked_doc(self.doctype, self.name, self.inter_company_invoice_reference) update_linked_doc(self.doctype, self.name, self.inter_company_invoice_reference)
self.update_advance_tax_references()
self.process_common_party_accounting() self.process_common_party_accounting()
@@ -1672,6 +1674,7 @@ class PurchaseInvoice(BuyingController):
check_if_return_invoice_linked_with_payment_entry(self) check_if_return_invoice_linked_with_payment_entry(self)
super().on_cancel() super().on_cancel()
PurchaseTaxWithholding(self).on_cancel()
self.check_on_hold_or_closed_status() self.check_on_hold_or_closed_status()
@@ -1718,10 +1721,9 @@ class PurchaseInvoice(BuyingController):
"Unreconcile Payment", "Unreconcile Payment",
"Unreconcile Payment Entries", "Unreconcile Payment Entries",
"Payment Ledger Entry", "Payment Ledger Entry",
"Tax Withheld Vouchers",
"Serial and Batch Bundle", "Serial and Batch Bundle",
"Tax Withholding Entry",
) )
self.update_advance_tax_references(cancel=1)
def update_project(self): def update_project(self):
projects = frappe._dict() projects = frappe._dict()
@@ -1844,102 +1846,6 @@ class PurchaseInvoice(BuyingController):
self.db_set("on_hold", 0) self.db_set("on_hold", 0)
self.db_set("release_date", None) self.db_set("release_date", None)
def set_tax_withholding(self):
self.set("advance_tax", [])
self.set("tax_withheld_vouchers", [])
if not self.apply_tds:
return
if self.apply_tds and not self.get("tax_withholding_category"):
self.tax_withholding_category = frappe.db.get_value(
"Supplier", self.supplier, "tax_withholding_category"
)
if not self.tax_withholding_category:
return
tax_withholding_details, advance_taxes, voucher_wise_amount = get_party_tax_withholding_details(
self, self.tax_withholding_category
)
# Adjust TDS paid on advances
self.allocate_advance_tds(tax_withholding_details, advance_taxes)
if not tax_withholding_details:
return
accounts = []
for d in self.taxes:
if d.account_head == tax_withholding_details.get("account_head"):
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)
## Add pending vouchers on which tax was withheld
for row in voucher_wise_amount:
self.append(
"tax_withheld_vouchers",
{
"voucher_name": row.voucher_name,
"voucher_type": row.voucher_type,
"taxable_amount": row.taxable_amount,
},
)
# calculate totals again after applying TDS
self.calculate_taxes_and_totals()
def allocate_advance_tds(self, tax_withholding_details, advance_taxes):
for tax in advance_taxes:
allocated_amount = 0
pending_amount = flt(tax.tax_amount - tax.allocated_amount)
if flt(tax_withholding_details.get("tax_amount")) >= pending_amount:
tax_withholding_details["tax_amount"] -= pending_amount
allocated_amount = pending_amount
elif (
flt(tax_withholding_details.get("tax_amount"))
and flt(tax_withholding_details.get("tax_amount")) < pending_amount
):
allocated_amount = tax_withholding_details["tax_amount"]
tax_withholding_details["tax_amount"] = 0
self.append(
"advance_tax",
{
"reference_type": "Payment Entry",
"reference_name": tax.parent,
"reference_detail": tax.name,
"account_head": tax.account_head,
"allocated_amount": allocated_amount,
},
)
def update_advance_tax_references(self, cancel=0):
for tax in self.get("advance_tax"):
at = frappe.qb.DocType("Advance Taxes and Charges").as_("at")
if cancel:
frappe.qb.update(at).set(
at.allocated_amount, at.allocated_amount - tax.allocated_amount
).where(at.name == tax.reference_detail).run()
else:
frappe.qb.update(at).set(
at.allocated_amount, at.allocated_amount + tax.allocated_amount
).where(at.name == tax.reference_detail).run()
def set_status(self, update=False, status=None, update_modified=True): def set_status(self, update=False, status=None, update_modified=True):
if self.is_new(): if self.is_new():
if self.get("amended_from"): if self.get("amended_from"):
@@ -2027,6 +1933,7 @@ def get_list_context(context=None):
"show_search": True, "show_search": True,
"no_breadcrumbs": True, "no_breadcrumbs": True,
"title": _("Purchase Invoices"), "title": _("Purchase Invoices"),
"list_template": "templates/includes/list/list.html",
} }
) )
return list_context return list_context
@@ -2097,10 +2004,26 @@ def make_purchase_receipt(source_name, target_doc=None, args=None):
if isinstance(args, str): if isinstance(args, str):
args = json.loads(args) args = json.loads(args)
def post_parent_process(source_parent, target_parent):
for row in target_parent.get("items"):
if row.get("qty") == 0:
target_parent.remove(row)
def update_item(obj, target, source_parent): def update_item(obj, target, source_parent):
target.qty = flt(obj.qty) - flt(obj.received_qty) from erpnext.controllers.sales_and_purchase_return import get_returned_qty_map_for_row
returned_qty_map = (
get_returned_qty_map_for_row(
source_parent.name, source_parent.supplier, obj.name, "Purchase Invoice"
)
or {}
)
target.qty = flt(obj.qty) - flt(obj.received_qty) - flt(returned_qty_map.get("qty"))
target.received_qty = flt(obj.qty) - flt(obj.received_qty) target.received_qty = flt(obj.qty) - flt(obj.received_qty)
target.stock_qty = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.conversion_factor) target.stock_qty = (flt(obj.qty) - flt(obj.received_qty) - flt(returned_qty_map.get("qty"))) * flt(
obj.conversion_factor
)
target.amount = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) target.amount = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate)
target.base_amount = ( target.base_amount = (
(flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) * flt(source_parent.conversion_rate) (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) * flt(source_parent.conversion_rate)
@@ -2139,6 +2062,7 @@ def make_purchase_receipt(source_name, target_doc=None, args=None):
"Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges"}, "Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges"},
}, },
target_doc, target_doc,
post_parent_process,
) )
return doc return doc

View File

@@ -1538,7 +1538,7 @@ class TestPurchaseInvoice(IntegrationTestCase, StockTestMixin):
# Create Payment Entry Against the order # Create Payment Entry Against the order
payment_entry = get_payment_entry(dt="Purchase Order", dn=po.name) payment_entry = get_payment_entry(dt="Purchase Order", dn=po.name)
payment_entry.paid_from = "Cash - _TC" payment_entry.paid_from = "Cash - _TC"
payment_entry.apply_tax_withholding_amount = 1 payment_entry.apply_tds = 1
payment_entry.tax_withholding_category = tax_withholding_category payment_entry.tax_withholding_category = tax_withholding_category
payment_entry.save() payment_entry.save()
payment_entry.submit() payment_entry.submit()
@@ -1591,12 +1591,26 @@ class TestPurchaseInvoice(IntegrationTestCase, StockTestMixin):
self.assertEqual(expected_gle[i][1], gle.amount) self.assertEqual(expected_gle[i][1], gle.amount)
payment_entry.load_from_db() payment_entry.load_from_db()
self.assertEqual(payment_entry.taxes[0].allocated_amount, 3000) tax_allocated = sum(
[
entry.withholding_amount
for entry in payment_entry.get("tax_withholding_entries", [])
if entry.taxable_name
]
)
self.assertEqual(tax_allocated, 3000)
purchase_invoice.cancel() purchase_invoice.cancel()
payment_entry.load_from_db() payment_entry.load_from_db()
self.assertEqual(payment_entry.taxes[0].allocated_amount, 0) tax_allocated = sum(
[
entry.withholding_amount
for entry in payment_entry.get("tax_withholding_entries", [])
if entry.taxable_name
]
)
self.assertEqual(tax_allocated, 0)
def test_purchase_gl_with_tax_withholding_tax(self): def test_purchase_gl_with_tax_withholding_tax(self):
company = "_Test Company" company = "_Test Company"
@@ -1631,7 +1645,6 @@ class TestPurchaseInvoice(IntegrationTestCase, StockTestMixin):
do_not_submit=1, do_not_submit=1,
) )
pi.apply_tds = 1 pi.apply_tds = 1
pi.tax_withholding_category = tax_withholding_category
pi.save() pi.save()
pi.submit() pi.submit()
@@ -2931,6 +2944,29 @@ class TestPurchaseInvoice(IntegrationTestCase, StockTestMixin):
pi.save() pi.save()
self.assertEqual(pi.discount_amount, discount_amount) self.assertEqual(pi.discount_amount, discount_amount)
def test_returned_item_purchase_receipt(self):
from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import (
make_purchase_receipt as make_purchase_receipt_from_pi,
)
item = create_item("_Test Returned Item Purchase Receipt", is_stock_item=1)
pi = make_purchase_invoice(item_code=item.name, qty=5, rate=100)
return_pi = make_purchase_invoice(
item_code=item.name,
is_return=1,
return_against=pi.name,
qty=-5,
do_not_submit=True,
)
return_pi.items[0].purchase_invoice_item = pi.items[0].name
return_pi.submit()
pr = make_purchase_receipt_from_pi(pi.name)
self.assertFalse(pr.items)
def set_advance_flag(company, flag, default_account): def set_advance_flag(company, flag, default_account):
frappe.db.set_value( frappe.db.set_value(

View File

@@ -44,6 +44,7 @@
"rate", "rate",
"amount", "amount",
"item_tax_template", "item_tax_template",
"tax_withholding_category",
"col_break4", "col_break4",
"base_rate", "base_rate",
"base_amount", "base_amount",
@@ -228,7 +229,6 @@
"reqd": 1 "reqd": 1
}, },
{ {
"default": "1",
"depends_on": "eval:doc.uom != doc.stock_uom", "depends_on": "eval:doc.uom != doc.stock_uom",
"fieldname": "conversion_factor", "fieldname": "conversion_factor",
"fieldtype": "Float", "fieldtype": "Float",
@@ -893,7 +893,7 @@
"default": "1", "default": "1",
"fieldname": "apply_tds", "fieldname": "apply_tds",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Apply TDS" "label": "Consider for Tax Withholding"
}, },
{ {
"depends_on": "eval:parent.update_stock == 1 && (doc.use_serial_batch_fields === 0 || doc.docstatus === 1)", "depends_on": "eval:parent.update_stock == 1 && (doc.use_serial_batch_fields === 0 || doc.docstatus === 1)",
@@ -979,13 +979,20 @@
"fieldtype": "Currency", "fieldtype": "Currency",
"label": "Distributed Discount Amount", "label": "Distributed Discount Amount",
"options": "currency" "options": "currency"
},
{
"fieldname": "tax_withholding_category",
"fieldtype": "Link",
"label": "Tax Withholding Category",
"options": "Tax Withholding Category",
"print_hide": 1
} }
], ],
"grid_page_length": 50, "grid_page_length": 50,
"idx": 1, "idx": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2025-10-14 13:00:54.441511", "modified": "2025-12-13 14:10:02.379392",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Purchase Invoice Item", "name": "Purchase Invoice Item",

View File

@@ -90,6 +90,7 @@ class PurchaseInvoiceItem(Document):
stock_qty: DF.Float stock_qty: DF.Float
stock_uom: DF.Link | None stock_uom: DF.Link | None
stock_uom_rate: DF.Currency stock_uom_rate: DF.Currency
tax_withholding_category: DF.Link | None
total_weight: DF.Float total_weight: DF.Float
uom: DF.Link uom: DF.Link
use_serial_batch_fields: DF.Check use_serial_batch_fields: DF.Check

View File

@@ -34,7 +34,8 @@
"base_net_amount", "base_net_amount",
"base_tax_amount", "base_tax_amount",
"base_total", "base_total",
"base_tax_amount_after_discount_amount" "base_tax_amount_after_discount_amount",
"dont_recompute_tax"
], ],
"fields": [ "fields": [
{ {
@@ -205,11 +206,11 @@
"fieldtype": "Column Break" "fieldtype": "Column Break"
}, },
{ {
"allow_on_submit": 1, "allow_on_submit": 1,
"fieldname": "project", "fieldname": "project",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Project", "label": "Project",
"options": "Project" "options": "Project"
}, },
{ {
"default": "0", "default": "0",
@@ -262,13 +263,22 @@
"print_hide": 1, "print_hide": 1,
"read_only": 1, "read_only": 1,
"report_hide": 1 "report_hide": 1
},
{
"default": "0",
"fieldname": "dont_recompute_tax",
"fieldtype": "Check",
"hidden": 1,
"label": "Don't Recompute Tax",
"print_hide": 1,
"read_only": 1
} }
], ],
"grid_page_length": 50, "grid_page_length": 50,
"idx": 1, "idx": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2025-07-24 15:08:44.433022", "modified": "2025-11-24 18:22:56.886010",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Purchase Taxes and Charges", "name": "Purchase Taxes and Charges",

View File

@@ -32,6 +32,7 @@ class PurchaseTaxesandCharges(Document):
] ]
cost_center: DF.Link | None cost_center: DF.Link | None
description: DF.SmallText description: DF.SmallText
dont_recompute_tax: DF.Check
included_in_paid_amount: DF.Check included_in_paid_amount: DF.Check
included_in_print_rate: DF.Check included_in_print_rate: DF.Check
is_tax_withholding_account: DF.Check is_tax_withholding_account: DF.Check
@@ -39,6 +40,7 @@ class PurchaseTaxesandCharges(Document):
parent: DF.Data parent: DF.Data
parentfield: DF.Data parentfield: DF.Data
parenttype: DF.Data parenttype: DF.Data
project: DF.Link | None
rate: DF.Float rate: DF.Float
row_id: DF.Data | None row_id: DF.Data | None
set_by_item_tax_template: DF.Check set_by_item_tax_template: DF.Check

View File

@@ -14,6 +14,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
erpnext.selling.SellingController erpnext.selling.SellingController
) { ) {
setup(doc) { setup(doc) {
this.setup_accounting_dimension_triggers();
this.setup_posting_date_time_check(); this.setup_posting_date_time_check();
super.setup(doc); super.setup(doc);
this.frm.make_methods = { this.frm.make_methods = {
@@ -24,6 +25,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
company() { company() {
super.company(); super.company();
erpnext.accounts.dimensions.update_dimension(this.frm, this.frm.doctype); erpnext.accounts.dimensions.update_dimension(this.frm, this.frm.doctype);
this.frm.clear_table("tax_withholding_entries");
} }
onload() { onload() {
var me = this; var me = this;
@@ -381,6 +383,9 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
), ),
}, },
function () { function () {
me.frm.doc.apply_tds =
me.frm.tax_withholding_category || me.frm.tax_withholding_group ? 1 : 0;
me.frm.clear_table("tax_withholding_entries");
me.apply_pricing_rule(); me.apply_pricing_rule();
} }
); );
@@ -597,6 +602,10 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
this.calculate_taxes_and_totals(); this.calculate_taxes_and_totals();
} }
apply_tds(frm) {
this.frm.clear_table("tax_withholding_entries");
}
}; };
// for backward compatibility: combine new and previous states // for backward compatibility: combine new and previous states
@@ -617,10 +626,6 @@ cur_frm.cscript.expense_account = function (doc, cdt, cdn) {
erpnext.utils.copy_value_in_all_rows(doc, cdt, cdn, "items", "expense_account"); erpnext.utils.copy_value_in_all_rows(doc, cdt, cdn, "items", "expense_account");
}; };
cur_frm.cscript.cost_center = function (doc, cdt, cdn) {
erpnext.utils.copy_value_in_all_rows(doc, cdt, cdn, "items", "cost_center");
};
frappe.ui.form.on("Sales Invoice", { frappe.ui.form.on("Sales Invoice", {
setup: function (frm) { setup: function (frm) {
frm.add_fetch("customer", "tax_id", "tax_id"); frm.add_fetch("customer", "tax_id", "tax_id");
@@ -817,6 +822,16 @@ frappe.ui.form.on("Sales Invoice", {
}, },
onload: function (frm) { onload: function (frm) {
frm.redemption_conversion_factor = null; frm.redemption_conversion_factor = null;
if (frm.doc.__onload && frm.doc.customer) {
if (frm.is_new()) {
frm.doc.apply_tds = frm.doc.__onload.apply_tds ? 1 : 0;
}
}
if (frm.is_new()) {
frm.clear_table("tax_withholding_entries");
}
}, },
update_stock: function (frm, dt, dn) { update_stock: function (frm, dt, dn) {

View File

@@ -28,6 +28,7 @@
"update_billed_amount_in_sales_order", "update_billed_amount_in_sales_order",
"update_billed_amount_in_delivery_note", "update_billed_amount_in_delivery_note",
"is_debit_note", "is_debit_note",
"apply_tds",
"amended_from", "amended_from",
"is_created_using_pos", "is_created_using_pos",
"pos_closing_entry", "pos_closing_entry",
@@ -90,6 +91,11 @@
"total_advance", "total_advance",
"outstanding_amount", "outstanding_amount",
"disable_rounded_total", "disable_rounded_total",
"section_tax_withholding_entry",
"tax_withholding_group",
"ignore_tax_withholding_threshold",
"override_tax_withholding_entries",
"tax_withholding_entries",
"section_break_49", "section_break_49",
"apply_discount_on", "apply_discount_on",
"base_discount_amount", "base_discount_amount",
@@ -2247,6 +2253,46 @@
"label": "Item Wise Tax Details", "label": "Item Wise Tax Details",
"no_copy": 1, "no_copy": 1,
"options": "Item Wise Tax Detail" "options": "Item Wise Tax Detail"
},
{
"default": "0",
"fieldname": "apply_tds",
"fieldtype": "Check",
"label": "Consider for Tax Withholding",
"print_hide": 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"
},
{
"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": "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"
} }
], ],
"grid_page_length": 50, "grid_page_length": 50,

View File

@@ -26,9 +26,7 @@ from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger
validate_docs_for_deferred_accounting, validate_docs_for_deferred_accounting,
validate_docs_for_voucher_types, validate_docs_for_voucher_types,
) )
from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category import ( from erpnext.accounts.doctype.tax_withholding_entry.tax_withholding_entry import SalesTaxWithholding
get_party_tax_withholding_details,
)
from erpnext.accounts.general_ledger import get_round_off_account_and_cost_center from erpnext.accounts.general_ledger import get_round_off_account_and_cost_center
from erpnext.accounts.party import get_due_date, get_party_account, get_party_details from erpnext.accounts.party import get_due_date, get_party_account, get_party_details
from erpnext.accounts.utils import ( from erpnext.accounts.utils import (
@@ -77,6 +75,7 @@ class SalesInvoice(SellingController):
from erpnext.accounts.doctype.sales_taxes_and_charges.sales_taxes_and_charges import ( from erpnext.accounts.doctype.sales_taxes_and_charges.sales_taxes_and_charges import (
SalesTaxesandCharges, SalesTaxesandCharges,
) )
from erpnext.accounts.doctype.tax_withholding_entry.tax_withholding_entry import TaxWithholdingEntry
from erpnext.selling.doctype.sales_team.sales_team import SalesTeam from erpnext.selling.doctype.sales_team.sales_team import SalesTeam
from erpnext.stock.doctype.packed_item.packed_item import PackedItem from erpnext.stock.doctype.packed_item.packed_item import PackedItem
@@ -90,6 +89,7 @@ class SalesInvoice(SellingController):
amended_from: DF.Link | None amended_from: DF.Link | None
amount_eligible_for_commission: DF.Currency amount_eligible_for_commission: DF.Currency
apply_discount_on: DF.Literal["", "Grand Total", "Net Total"] apply_discount_on: DF.Literal["", "Grand Total", "Net Total"]
apply_tds: DF.Check
auto_repeat: DF.Link | None auto_repeat: DF.Link | None
base_change_amount: DF.Currency base_change_amount: DF.Currency
base_discount_amount: DF.Currency base_discount_amount: DF.Currency
@@ -135,6 +135,7 @@ class SalesInvoice(SellingController):
has_subcontracted: DF.Check has_subcontracted: DF.Check
ignore_default_payment_terms_template: DF.Check ignore_default_payment_terms_template: DF.Check
ignore_pricing_rule: DF.Check ignore_pricing_rule: DF.Check
ignore_tax_withholding_threshold: DF.Check
in_words: DF.SmallText | None in_words: DF.SmallText | None
incoterm: DF.Link | None incoterm: DF.Link | None
inter_company_invoice_reference: DF.Link | None inter_company_invoice_reference: DF.Link | None
@@ -162,6 +163,7 @@ class SalesInvoice(SellingController):
only_include_allocated_payments: DF.Check only_include_allocated_payments: DF.Check
other_charges_calculation: DF.TextEditor | None other_charges_calculation: DF.TextEditor | None
outstanding_amount: DF.Currency outstanding_amount: DF.Currency
override_tax_withholding_entries: DF.Check
packed_items: DF.Table[PackedItem] packed_items: DF.Table[PackedItem]
paid_amount: DF.Currency paid_amount: DF.Currency
party_account_currency: DF.Link | None party_account_currency: DF.Link | None
@@ -214,6 +216,8 @@ class SalesInvoice(SellingController):
subscription: DF.Link | None subscription: DF.Link | None
tax_category: DF.Link | None tax_category: DF.Link | None
tax_id: DF.Data | None tax_id: DF.Data | None
tax_withholding_entries: DF.Table[TaxWithholdingEntry]
tax_withholding_group: DF.Link | None
taxes: DF.Table[SalesTaxesandCharges] taxes: DF.Table[SalesTaxesandCharges]
taxes_and_charges: DF.Link | None taxes_and_charges: DF.Link | None
tc_name: DF.Link | None tc_name: DF.Link | None
@@ -282,58 +286,13 @@ class SalesInvoice(SellingController):
self.indicator_color = "green" self.indicator_color = "green"
self.indicator_title = _("Paid") self.indicator_title = _("Paid")
def before_print(self, settings=None): def onload(self):
from frappe.contacts.doctype.address.address import get_address_display_list super().onload()
if self.customer:
super().before_print(settings) tax_withholding_category, tax_withholding_group = frappe.get_cached_value(
"Customer", self.customer, ["tax_withholding_category", "tax_withholding_group"]
company_details = frappe.get_value(
"Company", self.company, ["company_logo", "website", "phone_no", "email"], as_dict=True
)
required_fields = [
company_details.get("company_logo"),
company_details.get("phone_no"),
company_details.get("email"),
]
if not all(required_fields) and not frappe.has_permission("Company", "write", throw=False):
frappe.msgprint(
_(
"Some required Company details are missing. You don't have permission to update them. Please contact your System Manager."
)
)
return
if not self.company_address and not frappe.has_permission("Sales Invoice", "write", throw=False):
frappe.msgprint(
_(
"Company Address is missing. You don't have permission to update it. Please contact your System Manager."
)
)
return
address_display_list = get_address_display_list("Company", self.company)
address_line = address_display_list[0].get("address_line1") if address_display_list else ""
required_fields.append(self.company_address)
required_fields.append(address_line)
if not all(required_fields):
frappe.publish_realtime(
"sales_invoice_before_print",
{
"company_logo": company_details.get("company_logo"),
"website": company_details.get("website"),
"phone_no": company_details.get("phone_no"),
"email": company_details.get("email"),
"address_line": address_line,
"company": self.company,
"company_address": self.company_address,
"name": self.name,
},
user=frappe.session.user,
) )
self.set_onload("apply_tds", tax_withholding_category or tax_withholding_group)
def validate(self): def validate(self):
self.validate_auto_set_posting_time() self.validate_auto_set_posting_time()
@@ -344,7 +303,7 @@ class SalesInvoice(SellingController):
if not (self.is_pos or self.is_debit_note): if not (self.is_pos or self.is_debit_note):
self.so_dn_required() self.so_dn_required()
self.set_tax_withholding() SalesTaxWithholding(self).on_validate()
self.validate_proj_cust() self.validate_proj_cust()
self.validate_pos_return() self.validate_pos_return()
@@ -466,38 +425,6 @@ class SalesInvoice(SellingController):
for item in self.get("items"): for item in self.get("items"):
validate_account_head(item.idx, item.income_account, self.company, _("Income")) validate_account_head(item.idx, item.income_account, self.company, _("Income"))
def set_tax_withholding(self):
if self.get("is_opening") == "Yes":
return
tax_withholding_details = get_party_tax_withholding_details(self)
if not tax_withholding_details:
return
accounts = []
tax_withholding_account = tax_withholding_details.get("account_head")
for d in self.taxes:
if d.account_head == tax_withholding_account:
d.update(tax_withholding_details)
accounts.append(d.account_head)
if not accounts or tax_withholding_account not in accounts:
self.append("taxes", tax_withholding_details)
to_remove = [
d
for d in self.taxes
if not d.tax_amount and d.charge_type == "Actual" and d.account_head == tax_withholding_account
]
for d in to_remove:
self.remove(d)
# calculate totals again after applying TDS
self.calculate_taxes_and_totals()
def before_save(self): def before_save(self):
self.set_account_for_mode_of_payment() self.set_account_for_mode_of_payment()
self.set_paid_amount() self.set_paid_amount()
@@ -519,6 +446,8 @@ class SalesInvoice(SellingController):
# NOTE status updating bypassed for is_return # NOTE status updating bypassed for is_return
self.status_updater = [] self.status_updater = []
SalesTaxWithholding(self).on_submit()
self.update_status_updater_args() self.update_status_updater_args()
self.update_prevdoc_status() self.update_prevdoc_status()
@@ -658,6 +587,7 @@ class SalesInvoice(SellingController):
# Updating stock ledger should always be called after updating prevdoc status, # Updating stock ledger should always be called after updating prevdoc status,
# because updating reserved qty in bin depends upon updated delivered qty in SO # because updating reserved qty in bin depends upon updated delivered qty in SO
SalesTaxWithholding(self).on_cancel()
if self.update_stock == 1: if self.update_stock == 1:
self.update_stock_ledger() self.update_stock_ledger()
@@ -699,6 +629,7 @@ class SalesInvoice(SellingController):
"Unreconcile Payment Entries", "Unreconcile Payment Entries",
"Payment Ledger Entry", "Payment Ledger Entry",
"Serial and Batch Bundle", "Serial and Batch Bundle",
"Tax Withholding Entry",
) )
self.delete_auto_created_batches() self.delete_auto_created_batches()
@@ -1678,7 +1609,11 @@ class SalesInvoice(SellingController):
) )
for item in self.get("items"): for item in self.get("items"):
if flt(item.base_net_amount, item.precision("base_net_amount")) or item.is_fixed_asset: if (
flt(item.base_net_amount, item.precision("base_net_amount"))
or item.is_fixed_asset
or enable_discount_accounting
):
# Do not book income for transfer within same company # Do not book income for transfer within same company
if self.is_internal_transfer(): if self.is_internal_transfer():
continue continue
@@ -2375,6 +2310,7 @@ def get_list_context(context=None):
"show_search": True, "show_search": True,
"no_breadcrumbs": True, "no_breadcrumbs": True,
"title": _("Invoices"), "title": _("Invoices"),
"list_template": "templates/includes/list/list.html",
} }
) )
return list_context return list_context
@@ -2948,59 +2884,6 @@ def get_loyalty_programs(customer):
return lp_details return lp_details
@frappe.whitelist()
def save_company_master_details(name, company, details):
from frappe.utils import validate_email_address
if isinstance(details, str):
details = frappe.parse_json(details)
if details.get("email"):
validate_email_address(details.get("email"), throw=True)
company_fields = ["company_logo", "website", "phone_no", "email"]
company_fields_to_update = {field: details.get(field) for field in company_fields if details.get(field)}
if company_fields_to_update:
frappe.db.set_value("Company", company, company_fields_to_update)
company_address = details.get("company_address")
if details.get("address_line1"):
address_doc = frappe.get_doc(
{
"doctype": "Address",
"address_title": details.get("address_title"),
"address_type": details.get("address_type"),
"address_line1": details.get("address_line1"),
"address_line2": details.get("address_line2"),
"city": details.get("city"),
"state": details.get("state"),
"pincode": details.get("pincode"),
"country": details.get("country"),
"is_your_company_address": 1,
"links": [{"link_doctype": "Company", "link_name": company}],
}
)
address_doc.insert()
company_address = address_doc.name
if company_address:
company_address_display = frappe.db.get_value("Sales Invoice", name, "company_address_display")
if not company_address_display or details.get("address_line1"):
from frappe.query_builder import DocType
SalesInvoice = DocType("Sales Invoice")
(
frappe.qb.update(SalesInvoice)
.set(SalesInvoice.company_address, company_address)
.set(SalesInvoice.company_address_display, get_address_display(company_address))
.where(SalesInvoice.name == name)
).run()
return True
@frappe.whitelist() @frappe.whitelist()
def create_invoice_discounting(source_name, target_doc=None): def create_invoice_discounting(source_name, target_doc=None):
invoice = frappe.get_doc("Sales Invoice", source_name) invoice = frappe.get_doc("Sales Invoice", source_name)

View File

@@ -63,7 +63,8 @@ class TestSalesInvoice(ERPNextTestSuite):
set_default_account_for_mode_of_payment( set_default_account_for_mode_of_payment(
mode_of_payment, "_Test Company with perpetual inventory", "_Test Bank - TCP1" mode_of_payment, "_Test Company with perpetual inventory", "_Test Bank - TCP1"
) )
frappe.db.set_single_value("Accounts Settings", "acc_frozen_upto", None) for company in frappe.get_all("Company", pluck="name"):
frappe.db.set_value("Company", company, "accounts_frozen_till_date", None)
@change_settings( @change_settings(
"Accounts Settings", "Accounts Settings",
@@ -3398,8 +3399,8 @@ class TestSalesInvoice(ERPNextTestSuite):
si.commission_rate = commission_rate si.commission_rate = commission_rate
self.assertRaises(frappe.ValidationError, si.save) self.assertRaises(frappe.ValidationError, si.save)
@IntegrationTestCase.change_settings("Accounts Settings", {"acc_frozen_upto": add_days(getdate(), 1)})
def test_sales_invoice_submission_post_account_freezing_date(self): def test_sales_invoice_submission_post_account_freezing_date(self):
frappe.db.set_value("Company", "_Test Company", "accounts_frozen_till_date", add_days(getdate(), 1))
si = create_sales_invoice(do_not_save=True) si = create_sales_invoice(do_not_save=True)
si.posting_date = add_days(getdate(), 1) si.posting_date = add_days(getdate(), 1)
si.save() si.save()
@@ -3407,6 +3408,7 @@ class TestSalesInvoice(ERPNextTestSuite):
self.assertRaises(frappe.ValidationError, si.submit) self.assertRaises(frappe.ValidationError, si.submit)
si.posting_date = getdate() si.posting_date = getdate()
si.submit() si.submit()
frappe.db.set_value("Company", "_Test Company", "accounts_frozen_till_date", None)
@IntegrationTestCase.change_settings("Accounts Settings", {"over_billing_allowance": 0}) @IntegrationTestCase.change_settings("Accounts Settings", {"over_billing_allowance": 0})
def test_over_billing_case_against_delivery_note(self): def test_over_billing_case_against_delivery_note(self):
@@ -3473,7 +3475,7 @@ class TestSalesInvoice(ERPNextTestSuite):
si.save() si.save()
si.submit() si.submit()
frappe.db.set_single_value("Accounts Settings", "acc_frozen_upto", getdate("2019-01-31")) frappe.db.set_value("Company", "_Test Company", "accounts_frozen_till_date", getdate("2019-01-31"))
pda1 = frappe.get_doc( pda1 = frappe.get_doc(
dict( dict(

View File

@@ -43,12 +43,14 @@
"rate", "rate",
"amount", "amount",
"item_tax_template", "item_tax_template",
"tax_withholding_category",
"col_break3", "col_break3",
"base_rate", "base_rate",
"base_amount", "base_amount",
"pricing_rules", "pricing_rules",
"stock_uom_rate", "stock_uom_rate",
"is_free_item", "is_free_item",
"apply_tds",
"grant_commission", "grant_commission",
"section_break_21", "section_break_21",
"net_rate", "net_rate",
@@ -986,6 +988,21 @@
"hidden": 1, "hidden": 1,
"label": "SCIO Detail", "label": "SCIO Detail",
"read_only": 1 "read_only": 1
},
{
"fieldname": "tax_withholding_category",
"fieldtype": "Link",
"label": "Tax Withholding Category",
"options": "Tax Withholding Category",
"print_hide": 1
},
{
"default": "1",
"fieldname": "apply_tds",
"fieldtype": "Check",
"label": "Consider for Tax Withholding",
"print_hide": 1,
"read_only": 1
} }
], ],
"grid_page_length": 50, "grid_page_length": 50,

View File

@@ -24,6 +24,7 @@ class SalesInvoiceItem(Document):
actual_qty: DF.Float actual_qty: DF.Float
allow_zero_valuation_rate: DF.Check allow_zero_valuation_rate: DF.Check
amount: DF.Currency amount: DF.Currency
apply_tds: DF.Check
asset: DF.Link | None asset: DF.Link | None
barcode: DF.Data | None barcode: DF.Data | None
base_amount: DF.Currency base_amount: DF.Currency
@@ -95,6 +96,7 @@ class SalesInvoiceItem(Document):
stock_uom: DF.Link | None stock_uom: DF.Link | None
stock_uom_rate: DF.Currency stock_uom_rate: DF.Currency
target_warehouse: DF.Link | None target_warehouse: DF.Link | None
tax_withholding_category: DF.Link | None
total_weight: DF.Float total_weight: DF.Float
uom: DF.Link uom: DF.Link
use_serial_batch_fields: DF.Check use_serial_batch_fields: DF.Check

View File

@@ -14,6 +14,7 @@
"included_in_print_rate", "included_in_print_rate",
"included_in_paid_amount", "included_in_paid_amount",
"set_by_item_tax_template", "set_by_item_tax_template",
"is_tax_withholding_account",
"accounting_dimensions_section", "accounting_dimensions_section",
"cost_center", "cost_center",
"dimension_col_break", "dimension_col_break",
@@ -202,7 +203,7 @@
"fieldname": "dont_recompute_tax", "fieldname": "dont_recompute_tax",
"fieldtype": "Check", "fieldtype": "Check",
"hidden": 1, "hidden": 1,
"label": "Dont Recompute tax", "label": "Don't Recompute Tax",
"print_hide": 1, "print_hide": 1,
"read_only": 1 "read_only": 1
}, },
@@ -241,6 +242,13 @@
"print_hide": 1, "print_hide": 1,
"read_only": 1, "read_only": 1,
"report_hide": 1 "report_hide": 1
},
{
"default": "0",
"fieldname": "is_tax_withholding_account",
"fieldtype": "Check",
"label": "Is Tax Withholding Account",
"read_only": 1
} }
], ],
"idx": 1, "idx": 1,

View File

@@ -33,6 +33,8 @@ class SalesTaxesandCharges(Document):
dont_recompute_tax: DF.Check dont_recompute_tax: DF.Check
included_in_paid_amount: DF.Check included_in_paid_amount: DF.Check
included_in_print_rate: DF.Check included_in_print_rate: DF.Check
is_tax_withholding_account: DF.Check
item_wise_tax_detail: DF.Code | None
net_amount: DF.Currency net_amount: DF.Currency
parent: DF.Data parent: DF.Data
parentfield: DF.Data parentfield: DF.Data

View File

@@ -35,7 +35,7 @@
}, },
{ {
"fieldname": "rate", "fieldname": "rate",
"fieldtype": "Int", "fieldtype": "Currency",
"in_list_view": 1, "in_list_view": 1,
"label": "Rate", "label": "Rate",
"read_only": 1, "read_only": 1,
@@ -62,7 +62,7 @@
}, },
{ {
"fieldname": "amount", "fieldname": "amount",
"fieldtype": "Int", "fieldtype": "Currency",
"in_list_view": 1, "in_list_view": 1,
"label": "Amount", "label": "Amount",
"read_only": 1, "read_only": 1,
@@ -91,15 +91,16 @@
], ],
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2024-03-27 13:10:39.866399", "modified": "2025-12-10 08:06:40.611761",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Share Balance", "name": "Share Balance",
"owner": "Administrator", "owner": "Administrator",
"permissions": [], "permissions": [],
"quick_entry": 1, "quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "creation", "sort_field": "creation",
"sort_order": "DESC", "sort_order": "DESC",
"states": [], "states": [],
"track_changes": 1 "track_changes": 1
} }

View File

@@ -14,7 +14,7 @@ class ShareBalance(Document):
if TYPE_CHECKING: if TYPE_CHECKING:
from frappe.types import DF from frappe.types import DF
amount: DF.Int amount: DF.Currency
current_state: DF.Literal["", "Issued", "Purchased"] current_state: DF.Literal["", "Issued", "Purchased"]
from_no: DF.Int from_no: DF.Int
is_company: DF.Check is_company: DF.Check
@@ -22,7 +22,7 @@ class ShareBalance(Document):
parent: DF.Data parent: DF.Data
parentfield: DF.Data parentfield: DF.Data
parenttype: DF.Data parenttype: DF.Data
rate: DF.Int rate: DF.Currency
share_type: DF.Link share_type: DF.Link
to_no: DF.Int to_no: DF.Int
# end: auto-generated types # end: auto-generated types

View File

@@ -411,7 +411,10 @@ class Subscription(Document):
invoice.customer = self.party invoice.customer = self.party
else: else:
invoice.supplier = self.party invoice.supplier = self.party
if frappe.db.get_value("Supplier", self.party, "tax_withholding_category"): tax_withholding_category, tax_withholding_group = frappe.get_cached_value(
"Supplier", self.party, ["tax_withholding_category", "tax_withholding_group"]
)
if tax_withholding_category or tax_withholding_group:
invoice.apply_tds = 1 invoice.apply_tds = 1
# Add currency to invoice # Add currency to invoice

View File

@@ -27,7 +27,7 @@ class TestSubscription(IntegrationTestCase):
make_plans() make_plans()
create_parties() create_parties()
reset_settings() reset_settings()
frappe.db.set_single_value("Accounts Settings", "acc_frozen_upto", None) frappe.db.set_value("Company", "_Test Company", "accounts_frozen_till_date", None)
def tearDown(self): def tearDown(self):
frappe.db.rollback() frappe.db.rollback()

View File

@@ -1,47 +0,0 @@
{
"actions": [],
"autoname": "hash",
"creation": "2022-09-13 16:18:59.404842",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"voucher_type",
"voucher_name",
"taxable_amount"
],
"fields": [
{
"fieldname": "voucher_type",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Voucher Type"
},
{
"fieldname": "voucher_name",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Voucher Name"
},
{
"fieldname": "taxable_amount",
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Taxable Amount",
"options": "Company:company:default_currency"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-02-05 16:39:14.863698",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Tax Withheld Vouchers",
"naming_rule": "Random",
"owner": "Administrator",
"permissions": [],
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View File

@@ -16,4 +16,54 @@ frappe.ui.form.on("Tax Withholding Category", {
} }
}); });
}, },
refresh: function (frm) {
update_rates_read_only_state(frm);
},
disable_cumulative_threshold: function (frm) {
toggle_threshold_settings(frm, "disable_cumulative_threshold");
if (frm.doc.disable_cumulative_threshold) {
reset_rates_column(frm, "cumulative_threshold");
}
update_rates_read_only_state(frm);
},
disable_transaction_threshold: function (frm) {
toggle_threshold_settings(frm, "disable_transaction_threshold");
if (frm.doc.disable_transaction_threshold) {
reset_rates_column(frm, "single_threshold");
}
update_rates_read_only_state(frm);
},
}); });
function toggle_threshold_settings(frm, field_name) {
if (frm.doc[field_name]) {
const other_field =
field_name === "disable_cumulative_threshold"
? "disable_transaction_threshold"
: "disable_cumulative_threshold";
frm.set_value(other_field, 0);
}
}
function update_rates_read_only_state(frm) {
frm.fields_dict["rates"].grid.update_docfield_property(
"cumulative_threshold",
"read_only",
frm.doc.disable_cumulative_threshold
);
frm.fields_dict["rates"].grid.update_docfield_property(
"single_threshold",
"read_only",
frm.doc.disable_transaction_threshold
);
}
function reset_rates_column(frm, field_name) {
$.each(frm.doc.rates || [], function (i, row) {
row[field_name] = 0;
});
frm.refresh_field("rates");
}

View File

@@ -10,10 +10,12 @@
"field_order": [ "field_order": [
"category_details_section", "category_details_section",
"category_name", "category_name",
"round_off_tax_amount", "tax_deduction_basis",
"column_break_2", "column_break_2",
"consider_party_ledger_amount", "round_off_tax_amount",
"tax_on_excess_amount", "tax_on_excess_amount",
"disable_cumulative_threshold",
"disable_transaction_threshold",
"section_break_8", "section_break_8",
"rates", "rates",
"section_break_7", "section_break_7",
@@ -61,14 +63,7 @@
}, },
{ {
"default": "0", "default": "0",
"description": "Only payment entries with apply tax withholding unchecked will be considered for checking cumulative threshold breach", "description": "Tax withheld only for amount exceeding cumulative threshold",
"fieldname": "consider_party_ledger_amount",
"fieldtype": "Check",
"label": "Consider Entire Party Ledger Amount"
},
{
"default": "0",
"description": "Tax will be withheld only for amount exceeding the cumulative threshold",
"fieldname": "tax_on_excess_amount", "fieldname": "tax_on_excess_amount",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Only Deduct Tax On Excess Amount " "label": "Only Deduct Tax On Excess Amount "
@@ -79,6 +74,28 @@
"fieldname": "round_off_tax_amount", "fieldname": "round_off_tax_amount",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Round Off Tax Amount" "label": "Round Off Tax Amount"
},
{
"default": "Net Total",
"fieldname": "tax_deduction_basis",
"fieldtype": "Select",
"label": "Deduct Tax On Basis",
"options": "\nGross Total\nNet Total",
"reqd": 1
},
{
"default": "0",
"description": "When checked, only transaction threshold will be applied for transaction individually",
"fieldname": "disable_cumulative_threshold",
"fieldtype": "Check",
"label": "Disable Cumulative Threshold"
},
{
"default": "0",
"description": "When checked, only cumulative threshold will be applied",
"fieldname": "disable_transaction_threshold",
"fieldtype": "Check",
"label": "Disable Transaction Threshold"
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,

View File

@@ -1,14 +1,15 @@
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors # Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt # For license information, please see license.txt
from collections import defaultdict
import frappe import frappe
from frappe import _, qb from frappe import _
from frappe.model.document import Document from frappe.model.document import Document
from frappe.query_builder import Criterion from frappe.query_builder.functions import Sum
from frappe.query_builder.functions import Abs, Sum from frappe.utils import getdate
from frappe.utils import cint, flt, getdate
from erpnext import allow_regional
from erpnext.controllers.accounts_controller import validate_account_head from erpnext.controllers.accounts_controller import validate_account_head
@@ -28,30 +29,41 @@ class TaxWithholdingCategory(Document):
accounts: DF.Table[TaxWithholdingAccount] accounts: DF.Table[TaxWithholdingAccount]
category_name: DF.Data | None category_name: DF.Data | None
consider_party_ledger_amount: DF.Check disable_cumulative_threshold: DF.Check
disable_transaction_threshold: DF.Check
rates: DF.Table[TaxWithholdingRate] rates: DF.Table[TaxWithholdingRate]
round_off_tax_amount: DF.Check round_off_tax_amount: DF.Check
tax_deduction_basis: DF.Literal["", "Gross Total", "Net Total"]
tax_on_excess_amount: DF.Check tax_on_excess_amount: DF.Check
# end: auto-generated types # end: auto-generated types
def validate(self): def validate(self):
# TODO: Disable single threshold if tax on excess is enabled
self.validate_dates() self.validate_dates()
self.validate_companies_and_accounts() self.validate_companies_and_accounts()
self.validate_thresholds() self.validate_thresholds()
def validate_dates(self): def validate_dates(self):
last_to_date = None group_rates = defaultdict(list)
rates = sorted(self.get("rates"), key=lambda d: getdate(d.from_date)) for d in self.get("rates"):
for d in rates:
if getdate(d.from_date) >= getdate(d.to_date): if getdate(d.from_date) >= getdate(d.to_date):
frappe.throw(_("Row #{0}: From Date cannot be before To Date").format(d.idx)) frappe.throw(_("Row #{0}: From Date cannot be before To Date").format(d.idx))
group_rates[d.tax_withholding_group].append(d)
# validate overlapping of dates # Validate overlapping dates within each group
if last_to_date and getdate(d.from_date) < getdate(last_to_date): for group, rates in group_rates.items():
frappe.throw(_("Row #{0}: Dates overlapping with other row").format(d.idx)) rates = sorted(rates, key=lambda d: getdate(d.from_date))
last_to_date = None
last_to_date = d.to_date for d in rates:
if last_to_date and getdate(d.from_date) < getdate(last_to_date):
frappe.throw(
_("Row #{0}: Dates overlapping with other row in group {1}").format(
d.idx, group or "Default"
)
)
last_to_date = d.to_date
def validate_companies_and_accounts(self): def validate_companies_and_accounts(self):
existing_accounts = set() existing_accounts = set()
@@ -78,699 +90,168 @@ class TaxWithholdingCategory(Document):
).format(d.idx) ).format(d.idx)
) )
def get_applicable_tax_row(self, posting_date, tax_withholding_group):
for row in self.rates:
if (
getdate(row.from_date) <= getdate(posting_date) <= getdate(row.to_date)
and row.tax_withholding_group == tax_withholding_group
):
return row
def get_party_details(inv): frappe.throw(_("No Tax Withholding data found for the current posting date."))
party_type, party = "", ""
if inv.doctype == "Sales Invoice": def get_company_account(self, company):
party_type = "Customer" for row in self.accounts:
party = inv.customer if company == row.company:
else: return row.account
party_type = "Supplier"
party = inv.supplier
if not party:
frappe.throw(_("Please select {0} first").format(party_type))
return party_type, party
def get_party_tax_withholding_details(inv, tax_withholding_category=None):
if inv.doctype == "Payment Entry":
inv.tax_withholding_net_total = inv.net_total
inv.base_tax_withholding_net_total = inv.net_total
pan_no = ""
parties = []
party_type, party = get_party_details(inv)
has_pan_field = frappe.get_meta(party_type).has_field("pan")
if not tax_withholding_category:
if has_pan_field:
fields = ["tax_withholding_category", "pan"]
else:
fields = ["tax_withholding_category"]
tax_withholding_details = frappe.db.get_value(party_type, party, fields, as_dict=1)
tax_withholding_category = tax_withholding_details.get("tax_withholding_category")
pan_no = tax_withholding_details.get("pan")
if not tax_withholding_category:
return
# if tax_withholding_category passed as an argument but not pan_no
if not pan_no and has_pan_field:
pan_no = frappe.db.get_value(party_type, party, "pan")
# Get others suppliers with the same PAN No
if pan_no:
parties = frappe.get_all(party_type, filters={"pan": pan_no}, pluck="name")
if not parties:
parties.append(party)
posting_date = inv.get("posting_date") or inv.get("transaction_date")
tax_details = get_tax_withholding_details(tax_withholding_category, posting_date, inv.company)
if not tax_details:
frappe.msgprint(
_(
"Skipping Tax Withholding Category {0} as there is no associated account set for Company {1} in it."
).format(tax_withholding_category, inv.company)
)
if inv.doctype == "Purchase Invoice":
return {}, [], {}
return {}
if party_type == "Customer" and not tax_details.cumulative_threshold:
# TCS is only chargeable on sum of invoiced value
frappe.throw( frappe.throw(
_( _("No Tax withholding account set for Company {0} in Tax Withholding Category {1}.").format(
"Tax Withholding Category {} against Company {} for Customer {} should have Cumulative Threshold value." frappe.bold(company), frappe.bold(self.name)
).format(tax_withholding_category, inv.company, party)
)
tax_amount, tax_deducted, tax_deducted_on_advances, voucher_wise_amount = get_tax_amount(
party_type, parties, inv, tax_details, posting_date, pan_no
)
if party_type == "Supplier":
tax_row = get_tax_row_for_tds(tax_details, tax_amount)
else:
tax_row = get_tax_row_for_tcs(inv, tax_details, tax_amount, tax_deducted)
cost_center = get_cost_center(inv)
tax_row.update(
{
"cost_center": cost_center,
"is_tax_withholding_account": 1,
}
)
if cint(tax_details.round_off_tax_amount):
inv.round_off_applicable_accounts_for_tax_withholding = tax_details.account_head
if inv.doctype == "Purchase Invoice":
return tax_row, tax_deducted_on_advances, voucher_wise_amount
else:
return tax_row
def get_cost_center(inv):
cost_center = frappe.get_cached_value("Company", inv.company, "cost_center")
if len(inv.get("taxes", [])) > 0:
cost_center = inv.get("taxes")[0].cost_center
return cost_center
def get_tax_withholding_details(tax_withholding_category, posting_date, company):
tax_withholding = frappe.get_doc("Tax Withholding Category", tax_withholding_category)
tax_rate_detail = get_tax_withholding_rates(tax_withholding, posting_date)
for account_detail in tax_withholding.accounts:
if company == account_detail.company:
return frappe._dict(
{
"tax_withholding_category": tax_withholding_category,
"account_head": account_detail.account,
"rate": tax_rate_detail.tax_withholding_rate,
"from_date": tax_rate_detail.from_date,
"to_date": tax_rate_detail.to_date,
"threshold": tax_rate_detail.single_threshold,
"cumulative_threshold": tax_rate_detail.cumulative_threshold,
"description": tax_withholding.category_name
if tax_withholding.category_name
else tax_withholding_category,
"consider_party_ledger_amount": tax_withholding.consider_party_ledger_amount,
"tax_on_excess_amount": tax_withholding.tax_on_excess_amount,
"round_off_tax_amount": tax_withholding.round_off_tax_amount,
}
) )
def get_tax_withholding_rates(tax_withholding, posting_date):
# returns the row that matches with the fiscal year from posting date
for rate in tax_withholding.rates:
if getdate(rate.from_date) <= getdate(posting_date) <= getdate(rate.to_date):
return rate
frappe.throw(_("No Tax Withholding data found for the current posting date."))
def get_tax_row_for_tcs(inv, tax_details, tax_amount, tax_deducted):
row = {
"category": "Total",
"charge_type": "Actual",
"tax_amount": tax_amount,
"description": tax_details.description,
"account_head": tax_details.account_head,
}
if tax_deducted:
# TCS already deducted on previous invoices
# So, TCS will be calculated by 'Previous Row Total'
taxes_excluding_tcs = [d for d in inv.taxes if d.account_head != tax_details.account_head]
if taxes_excluding_tcs:
# chargeable amount is the total amount after other charges are applied
row.update(
{
"charge_type": "On Previous Row Total",
"row_id": len(taxes_excluding_tcs),
"rate": tax_details.rate,
}
)
else:
# if only TCS is to be charged, then net total is chargeable amount
row.update({"charge_type": "On Net Total", "rate": tax_details.rate})
return row
def get_tax_row_for_tds(tax_details, tax_amount):
return {
"category": "Total",
"charge_type": "Actual",
"tax_amount": tax_amount,
"add_deduct_tax": "Deduct",
"description": tax_details.description,
"account_head": tax_details.account_head,
}
def get_lower_deduction_certificate(company, posting_date, tax_details, pan_no):
ldc_name = frappe.db.get_value(
"Lower Deduction Certificate",
{
"pan_no": pan_no,
"tax_withholding_category": tax_details.tax_withholding_category,
"valid_from": ("<=", posting_date),
"valid_upto": (">=", posting_date),
"company": company,
},
"name",
)
if ldc_name:
return frappe.get_doc("Lower Deduction Certificate", ldc_name)
def get_tax_amount(party_type, parties, inv, tax_details, posting_date, pan_no=None):
vouchers, voucher_wise_amount = get_invoice_vouchers(
parties,
tax_details,
inv.company,
party_type=party_type,
)
payment_entry_vouchers = get_payment_entry_vouchers(
parties, tax_details, inv.company, party_type=party_type
)
advance_vouchers = get_advance_vouchers(
parties,
company=inv.company,
from_date=tax_details.from_date,
to_date=tax_details.to_date,
party_type=party_type,
)
taxable_vouchers = vouchers + advance_vouchers + payment_entry_vouchers
tax_deducted_on_advances = 0
if inv.doctype == "Purchase Invoice":
tax_deducted_on_advances = get_taxes_deducted_on_advances_allocated(inv, tax_details)
tax_deducted = 0
if taxable_vouchers:
tax_deducted = get_deducted_tax(taxable_vouchers, tax_details)
# If advance is outside the current tax withholding period (usually a fiscal year), `get_deducted_tax` won't fetch it.
# updating `tax_deducted` with correct advance tax value (from current and previous previous withholding periods), will allow the
# rest of the below logic to function properly
# ---FY 2023-------------||---------------------FY 2024-----------------------||--
# ---Advance-------------||---------Inv_1--------Inv_2------------------------||--
if tax_deducted_on_advances:
tax_deducted += get_advance_tax_across_fiscal_year(tax_deducted_on_advances, tax_details)
tax_amount = 0
if party_type == "Supplier":
# if tds account is changed.
if not tax_deducted:
tax_deducted = is_tax_deducted_on_the_basis_of_inv(vouchers)
ldc = get_lower_deduction_certificate(inv.company, posting_date, tax_details, pan_no)
if tax_deducted:
net_total = inv.tax_withholding_net_total
if ldc:
limit_consumed = get_limit_consumed(ldc, parties)
if is_valid_certificate(ldc, posting_date, limit_consumed):
tax_amount = get_lower_deduction_amount(
net_total, limit_consumed, ldc.certificate_limit, ldc.rate, tax_details
)
else:
tax_amount = net_total * tax_details.rate / 100
else:
tax_amount = net_total * tax_details.rate / 100
# once tds is deducted, not need to add vouchers in the invoice
voucher_wise_amount = {}
else:
tax_amount = get_tds_amount(ldc, parties, inv, tax_details, voucher_wise_amount)
elif party_type == "Customer":
if tax_deducted:
# if already TCS is charged, then amount will be calculated based on 'Previous Row Total'
tax_amount = 0
else:
# if no TCS has been charged in FY,
# then chargeable value is "prev invoices + advances - advance_adjusted" value which cross the threshold
tax_amount = get_tcs_amount(parties, inv, tax_details, vouchers, advance_vouchers)
if cint(tax_details.round_off_tax_amount):
tax_amount = normal_round(tax_amount)
return tax_amount, tax_deducted, tax_deducted_on_advances, voucher_wise_amount
def is_tax_deducted_on_the_basis_of_inv(vouchers):
return frappe.db.exists(
"Purchase Taxes and Charges",
{
"parent": ["in", vouchers],
"is_tax_withholding_account": 1,
"parenttype": "Purchase Invoice",
"base_tax_amount_after_discount_amount": [">", 0],
},
)
def get_invoice_vouchers(parties, tax_details, company, party_type="Supplier"):
voucher_wise_amount = []
vouchers = []
ldcs = frappe.db.get_all(
"Lower Deduction Certificate",
filters={
"valid_from": [">=", tax_details.from_date],
"valid_upto": ["<=", tax_details.to_date],
"company": company,
"supplier": ["in", parties],
},
fields=["supplier", "valid_from", "valid_upto", "rate"],
)
doctype = "Purchase Invoice" if party_type == "Supplier" else "Sales Invoice"
field = [
"base_tax_withholding_net_total as base_net_total" if party_type == "Supplier" else "base_net_total",
"name",
"grand_total",
"posting_date",
]
filters = {
"company": company,
frappe.scrub(party_type): ["in", parties],
"posting_date": ["between", (tax_details.from_date, tax_details.to_date)],
"is_opening": "No",
"docstatus": 1,
}
if doctype != "Sales Invoice":
filters.update(
{"apply_tds": 1, "tax_withholding_category": tax_details.get("tax_withholding_category")}
) )
invoices_details = frappe.get_all(doctype, filters=filters, fields=field)
for d in invoices_details: class TaxWithholdingDetails:
d = frappe._dict( def __init__(
{ self,
"voucher_name": d.name, tax_withholding_categories: list[str],
"voucher_type": doctype, tax_withholding_group: str,
"taxable_amount": d.base_net_total, posting_date: str,
"grand_total": d.grand_total, party_type: str,
"posting_date": d.posting_date, party: str,
} company: str,
) ):
self.tax_withholding_categories = tax_withholding_categories
self.tax_withholding_group = tax_withholding_group
self.posting_date = posting_date
self.party_type = party_type
self.party = party
self.company = company
if ldc := [x for x in ldcs if d.posting_date >= x.valid_from and d.posting_date <= x.valid_upto]: def get(self) -> list:
if ldc[0].supplier in parties and ldc[0].rate == 0:
d.update({"taxable_amount": 0})
vouchers.append(d.voucher_name)
voucher_wise_amount.append(d)
journal_entries_details = frappe.db.sql(
""" """
SELECT j.name, ja.credit - ja.debit AS amount, ja.reference_type Fetches tax withholding categories based on the provided parameters.
FROM `tabJournal Entry` j, `tabJournal Entry Account` ja """
WHERE category_details = frappe._dict()
j.name = ja.parent if not self.tax_withholding_categories:
AND j.docstatus = 1 return category_details
AND j.is_opening = 'No'
AND j.posting_date between %s and %s
AND ja.party in %s
AND j.apply_tds = 1
AND j.tax_withholding_category = %s
AND j.company = %s
""",
(
tax_details.from_date,
tax_details.to_date,
tuple(parties),
tax_details.get("tax_withholding_category"),
company,
),
as_dict=1,
)
for d in journal_entries_details: ldc_details = self.get_ldc_details()
vouchers.append(d.name)
voucher_wise_amount.append( for category_name in self.tax_withholding_categories:
frappe._dict( doc: TaxWithholdingCategory = frappe.get_cached_doc("Tax Withholding Category", category_name)
{ row = doc.get_applicable_tax_row(self.posting_date, self.tax_withholding_group)
"voucher_name": d.name, account_head = doc.get_company_account(self.company)
"voucher_type": "Journal Entry",
"taxable_amount": d.amount, category_detail = frappe._dict(
"reference_type": d.reference_type, name=category_name,
} description=doc.category_name,
account_head=account_head,
# rates
tax_rate=row.tax_withholding_rate,
from_date=row.from_date,
to_date=row.to_date,
single_threshold=row.single_threshold,
cumulative_threshold=row.cumulative_threshold,
# settings
tax_deduction_basis=doc.tax_deduction_basis,
round_off_tax_amount=doc.round_off_tax_amount,
tax_on_excess_amount=doc.tax_on_excess_amount,
disable_cumulative_threshold=doc.disable_cumulative_threshold,
disable_transaction_threshold=doc.disable_transaction_threshold,
taxable_amount=0,
)
# ldc (only if valid based on posting date)
if ldc_detail := ldc_details.get(category_name):
category_detail.update(ldc_detail)
category_details[category_name] = category_detail
return category_details
def get_ldc_details(self):
"""
Fetches the Lower Deduction Certificate (LDC) details for the given party.
Assumes that only one LDC per category can be valid at a time.
"""
ldc_details = {}
if self.party_type != "Supplier":
return ldc_details
# NOTE: This can be a configurable option
# To check if filter by tax_id is needed
tax_id = get_tax_id_for_party(self.party_type, self.party)
# ldc details
ldc_records = self.get_valid_ldc_records(tax_id)
if not ldc_records:
return ldc_details
ldc_names = [ldc.name for ldc in ldc_records]
ldc_utilization_map = self.get_ldc_utilization_by_category(ldc_names, tax_id)
# map
for ldc in ldc_records:
category_name = ldc.tax_withholding_category
unutilized_amount = ldc.certificate_limit - (ldc_utilization_map.get(ldc.name) or 0)
if not unutilized_amount:
continue
ldc_details[category_name] = dict(
ldc_certificate=ldc.name,
ldc_unutilized_amount=unutilized_amount,
ldc_rate=ldc.rate,
)
return ldc_details
def get_valid_ldc_records(self, tax_id):
ldc = frappe.qb.DocType("Lower Deduction Certificate")
query = (
frappe.qb.from_(ldc)
.select(
ldc.name,
ldc.tax_withholding_category,
ldc.rate,
ldc.certificate_limit,
)
.where(
(ldc.valid_from <= self.posting_date)
& (ldc.valid_upto >= self.posting_date)
& (ldc.company == self.company)
& ldc.tax_withholding_category.isin(self.tax_withholding_categories)
) )
) )
return vouchers, voucher_wise_amount query = query.where(ldc.pan_no == tax_id) if tax_id else query.where(ldc.supplier == self.party)
return query.run(as_dict=True)
def get_payment_entry_vouchers(parties, tax_details, company, party_type="Supplier"): def get_ldc_utilization_by_category(self, ldc_names, tax_id):
payment_entry_filters = { twe = frappe.qb.DocType("Tax Withholding Entry")
"party_type": party_type, query = (
"party": ("in", parties), frappe.qb.from_(twe)
"docstatus": 1, .select(twe.lower_deduction_certificate, Sum(twe.taxable_amount).as_("limit_consumed"))
"apply_tax_withholding_amount": 1, .where(
"posting_date": ["between", (tax_details.from_date, tax_details.to_date)], (twe.company == self.company)
"tax_withholding_category": tax_details.get("tax_withholding_category"), & (twe.party_type == self.party_type)
"company": company, & (twe.tax_withholding_category.isin(self.tax_withholding_categories))
} & (twe.lower_deduction_certificate.isin(ldc_names))
& (twe.docstatus == 1)
return frappe.db.get_all("Payment Entry", filters=payment_entry_filters, pluck="name") & (twe.status.isin(["Settled", "Over Withheld"]))
def get_advance_vouchers(parties, company=None, from_date=None, to_date=None, party_type="Supplier"):
"""
Use Payment Ledger to fetch unallocated Advance Payments
"""
if party_type == "Supplier":
return []
ple = qb.DocType("Payment Ledger Entry")
conditions = []
conditions.append(ple.amount.lt(0))
conditions.append(ple.delinked == 0)
conditions.append(ple.party_type == party_type)
conditions.append(ple.party.isin(parties))
conditions.append(ple.voucher_no == ple.against_voucher_no)
if company:
conditions.append(ple.company == company)
if from_date and to_date:
conditions.append(ple.posting_date[from_date:to_date])
advances = qb.from_(ple).select(ple.voucher_no).distinct().where(Criterion.all(conditions)).run(as_list=1)
if advances:
advances = [x[0] for x in advances]
return advances
def get_taxes_deducted_on_advances_allocated(inv, tax_details):
tax_info = []
if inv.get("advances"):
advances = [d.reference_name for d in inv.get("advances")]
if advances:
pe = frappe.qb.DocType("Payment Entry").as_("pe")
at = frappe.qb.DocType("Advance Taxes and Charges").as_("at")
tax_info = (
frappe.qb.from_(at)
.inner_join(pe)
.on(pe.name == at.parent)
.select(pe.posting_date, at.parent, at.name, at.tax_amount, at.allocated_amount)
.where(pe.tax_withholding_category == tax_details.get("tax_withholding_category"))
.where(at.parent.isin(advances))
.where(at.account_head == tax_details.account_head)
.run(as_dict=True)
)
return tax_info
def get_deducted_tax(taxable_vouchers, tax_details):
# check if TDS / TCS account is already charged on taxable vouchers
filters = {
"is_cancelled": 0,
"credit": [">", 0],
"posting_date": ["between", (tax_details.from_date, tax_details.to_date)],
"account": tax_details.account_head,
"voucher_no": ["in", taxable_vouchers],
}
field = "credit"
entries = frappe.db.get_all("GL Entry", filters, pluck=field)
return sum(entries)
def get_advance_tax_across_fiscal_year(tax_deducted_on_advances, tax_details):
"""
Only applies for Taxes deducted on Advance Payments
"""
advance_tax_from_across_fiscal_year = sum(
[adv.tax_amount for adv in tax_deducted_on_advances if adv.posting_date < tax_details.from_date]
)
return advance_tax_from_across_fiscal_year
def get_tds_amount(ldc, parties, inv, tax_details, voucher_wise_amount):
tds_amount = 0
pi_grand_total = 0
pi_base_net_total = 0
jv_credit_amt = 0
pe_credit_amt = 0
for row in voucher_wise_amount:
if row.voucher_type == "Purchase Invoice":
pi_grand_total += row.get("grand_total", 0)
pi_base_net_total += row.get("taxable_amount", 0)
if row.voucher_type == "Journal Entry" and row.reference_type != "Purchase Invoice":
jv_credit_amt += row.get("taxable_amount", 0)
## for TDS to be deducted on advances
pe_filters = {
"party_type": "Supplier",
"party": ("in", parties),
"docstatus": 1,
"apply_tax_withholding_amount": 1,
"unallocated_amount": (">", 0),
"posting_date": ["between", (tax_details.from_date, tax_details.to_date)],
"tax_withholding_category": tax_details.get("tax_withholding_category"),
"company": inv.company,
}
consider_party_ledger_amt = cint(tax_details.consider_party_ledger_amount)
if consider_party_ledger_amt:
pe_filters.pop("apply_tax_withholding_amount", None)
pe_filters.pop("tax_withholding_category", None)
# Get Amount via payment entry
payment_entries = frappe.db.get_all(
"Payment Entry",
filters=pe_filters,
fields=["name", "unallocated_amount as taxable_amount", "payment_type"],
)
for row in payment_entries:
value = row.taxable_amount if row.payment_type == "Pay" else -1 * row.taxable_amount
pe_credit_amt += value
voucher_wise_amount.append(
frappe._dict(
{
"voucher_name": row.name,
"voucher_type": "Payment Entry",
"taxable_amount": value,
}
) )
.groupby(twe.lower_deduction_certificate)
) )
threshold = tax_details.get("threshold", 0) query = query.where(twe.tax_id == tax_id) if tax_id else query.where(twe.party == self.party)
cumulative_threshold = tax_details.get("cumulative_threshold", 0)
supp_credit_amt = jv_credit_amt + pe_credit_amt + inv.get("tax_withholding_net_total", 0)
tax_withholding_net_total = inv.get("base_tax_withholding_net_total", 0)
# if consider_party_ledger_amount is checked, then threshold will be based on grand total return frappe._dict(query.run())
amt_for_threshold = pi_grand_total if consider_party_ledger_amt else pi_base_net_total
cumulative_threshold_breached = (
cumulative_threshold and (supp_credit_amt + amt_for_threshold) >= cumulative_threshold
)
if (threshold and tax_withholding_net_total >= threshold) or (cumulative_threshold_breached):
supp_credit_amt += pi_base_net_total
if cumulative_threshold_breached and cint(tax_details.tax_on_excess_amount):
supp_credit_amt = pi_base_net_total + tax_withholding_net_total - cumulative_threshold
if ldc and is_valid_certificate(ldc, inv.get("posting_date") or inv.get("transaction_date"), 0):
tds_amount = get_lower_deduction_amount(
supp_credit_amt, 0, ldc.certificate_limit, ldc.rate, tax_details
)
else:
tds_amount = supp_credit_amt * tax_details.rate / 100 if supp_credit_amt > 0 else 0
return tds_amount
def get_tcs_amount(parties, inv, tax_details, vouchers, adv_vouchers): @allow_regional
tcs_amount = 0 def get_tax_id_for_party(party_type, party):
ple = qb.DocType("Payment Ledger Entry") return None
# sum of debit entries made from sales invoices
invoiced_amt = (
frappe.db.get_value(
"GL Entry",
{
"is_cancelled": 0,
"party_type": "Customer",
"party": ["in", parties],
"company": inv.company,
"voucher_no": ["in", vouchers],
},
[{"SUM": "debit"}],
)
or 0.0
)
# sum of credit entries made from PE / JV with unset 'against voucher'
conditions = []
conditions.append(ple.amount.lt(0))
conditions.append(ple.delinked == 0)
conditions.append(ple.party_type == "Customer")
conditions.append(ple.party.isin(parties))
conditions.append(ple.voucher_no == ple.against_voucher_no)
conditions.append(ple.company == inv.company)
conditions.append(ple.posting_date[tax_details.from_date : tax_details.to_date])
advance_amt = (
qb.from_(ple).select(Abs(Sum(ple.amount))).where(Criterion.all(conditions)).run()[0][0] or 0.0
)
# sum of credit entries made from sales invoice
credit_note_amt = sum(
frappe.db.get_all(
"GL Entry",
{
"is_cancelled": 0,
"credit": [">", 0],
"party_type": "Customer",
"party": ["in", parties],
"posting_date": ["between", (tax_details.from_date, tax_details.to_date)],
"company": inv.company,
"voucher_type": "Sales Invoice",
},
pluck="credit",
)
)
cumulative_threshold = tax_details.get("cumulative_threshold", 0)
advance_adjusted = get_advance_adjusted_in_invoice(inv)
current_invoice_total = get_invoice_total_without_tcs(inv, tax_details)
total_invoiced_amt = (
current_invoice_total + invoiced_amt + advance_amt - credit_note_amt - advance_adjusted
)
if cumulative_threshold and total_invoiced_amt >= cumulative_threshold:
chargeable_amt = total_invoiced_amt - cumulative_threshold
tcs_amount = chargeable_amt * tax_details.rate / 100 if chargeable_amt > 0 else 0
return tcs_amount
def get_advance_adjusted_in_invoice(inv):
advances_adjusted = 0
for row in inv.get("advances", []):
advances_adjusted += row.allocated_amount
return advances_adjusted
def get_invoice_total_without_tcs(inv, tax_details):
tcs_tax_row = [d for d in inv.taxes if d.account_head == tax_details.account_head]
tcs_tax_row_amount = tcs_tax_row[0].base_tax_amount if tcs_tax_row else 0
return inv.grand_total - tcs_tax_row_amount
def get_limit_consumed(ldc, parties):
limit_consumed = frappe.db.get_value(
"Purchase Invoice",
{
"supplier": ("in", parties),
"apply_tds": 1,
"docstatus": 1,
"tax_withholding_category": ldc.tax_withholding_category,
"posting_date": ("between", (ldc.valid_from, ldc.valid_upto)),
"company": ldc.company,
},
[{"SUM": "tax_withholding_net_total"}],
)
return limit_consumed
def get_lower_deduction_amount(current_amount, limit_consumed, certificate_limit, rate, tax_details):
if certificate_limit - flt(limit_consumed) - flt(current_amount) >= 0:
return current_amount * rate / 100
else:
ltds_amount = certificate_limit - flt(limit_consumed)
tds_amount = current_amount - ltds_amount
return ltds_amount * rate / 100 + tds_amount * tax_details.rate / 100
def is_valid_certificate(ldc, posting_date, limit_consumed):
available_amount = flt(ldc.certificate_limit) - flt(limit_consumed)
if (getdate(ldc.valid_from) <= getdate(posting_date) <= getdate(ldc.valid_upto)) and available_amount > 0:
return True
return False
def normal_round(number):
"""
Rounds a number to the nearest integer.
:param number: The number to round.
"""
decimal_part = number - int(number)
if decimal_part >= 0.5:
decimal_part = 1
else:
decimal_part = 0
number = int(number) + decimal_part
return number

View File

@@ -0,0 +1,237 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2025-06-20 04:55:28.583171",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"section_break_krko",
"company",
"party_type",
"party",
"tax_id",
"column_break_egzm",
"tax_withholding_category",
"tax_withholding_group",
"taxable_amount",
"tax_rate",
"withholding_amount",
"target_section",
"taxable_doctype",
"taxable_name",
"taxable_date",
"currency",
"conversion_rate",
"column_break_fqoe",
"under_withheld_reason",
"lower_deduction_certificate",
"source_section",
"withholding_doctype",
"withholding_name",
"withholding_date",
"column_break_dahw",
"section_break_ggna",
"status",
"column_break_jfjf",
"created_by_migration"
],
"fields": [
{
"fieldname": "section_break_krko",
"fieldtype": "Section Break"
},
{
"fieldname": "party_type",
"fieldtype": "Link",
"label": "Party Type",
"options": "DocType",
"read_only": 1
},
{
"fieldname": "party",
"fieldtype": "Dynamic Link",
"label": "Party",
"options": "party_type",
"read_only": 1
},
{
"fieldname": "tax_id",
"fieldtype": "Data",
"label": "Tax ID",
"read_only": 1
},
{
"fieldname": "tax_withholding_category",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Tax Withholding Category",
"options": "Tax Withholding Category",
"read_only": 1
},
{
"fieldname": "column_break_egzm",
"fieldtype": "Column Break"
},
{
"columns": 1,
"fieldname": "tax_rate",
"fieldtype": "Percent",
"in_list_view": 1,
"label": "Tax Rate"
},
{
"columns": 1,
"fieldname": "taxable_amount",
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Base Taxable Amount",
"options": "Company:company:default_currency"
},
{
"description": "Transaction from which tax is withheld",
"fieldname": "source_section",
"fieldtype": "Section Break",
"label": "Deducted From"
},
{
"fieldname": "column_break_dahw",
"fieldtype": "Column Break"
},
{
"description": "Transaction for which tax is withheld",
"fieldname": "target_section",
"fieldtype": "Section Break",
"label": "Applicable For"
},
{
"fieldname": "column_break_fqoe",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_ggna",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_jfjf",
"fieldtype": "Column Break"
},
{
"fieldname": "lower_deduction_certificate",
"fieldtype": "Link",
"label": "Lower Deduction Certificate",
"options": "Lower Deduction Certificate",
"read_only": 1
},
{
"fieldname": "status",
"fieldtype": "Select",
"label": "Status",
"options": "\nSettled\nUnder Withheld\nOver Withheld\nDuplicate\nCancelled",
"read_only": 1
},
{
"fieldname": "currency",
"fieldtype": "Link",
"label": "Currency",
"options": "Currency",
"read_only": 1
},
{
"fieldname": "conversion_rate",
"fieldtype": "Float",
"label": "Exchange Rate",
"precision": "9",
"read_only": 1
},
{
"fieldname": "withholding_doctype",
"fieldtype": "Link",
"label": "Withholding Document Type",
"options": "DocType"
},
{
"fieldname": "withholding_name",
"fieldtype": "Dynamic Link",
"label": "Withholding Document Name",
"options": "withholding_doctype"
},
{
"fieldname": "taxable_doctype",
"fieldtype": "Link",
"label": "Taxable Document Type",
"options": "DocType"
},
{
"fieldname": "taxable_name",
"fieldtype": "Dynamic Link",
"in_list_view": 1,
"label": "Taxable Document Name",
"options": "taxable_doctype"
},
{
"fieldname": "taxable_date",
"fieldtype": "Date",
"label": "Taxable Date",
"read_only": 1
},
{
"fieldname": "withholding_date",
"fieldtype": "Date",
"label": "Withholding Date",
"read_only": 1
},
{
"fieldname": "under_withheld_reason",
"fieldtype": "Select",
"label": "Under Withheld Reason",
"options": "\nThreshold Exemption\nLower Deduction Certificate",
"read_only": 1
},
{
"columns": 1,
"fieldname": "withholding_amount",
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Base Tax Withheld",
"options": "Company:company:default_currency",
"read_only": 1
},
{
"columns": 1,
"fieldname": "tax_withholding_group",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Tax Withholding Group",
"options": "Tax Withholding Group",
"read_only": 1
},
{
"fieldname": "company",
"fieldtype": "Link",
"label": "Company",
"options": "Company"
},
{
"default": "0",
"fieldname": "created_by_migration",
"fieldtype": "Check",
"hidden": 1,
"label": "Created By Migration",
"read_only": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-12-22 09:07:26.701207",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Tax Withholding Entry",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
# 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 IntegrationTestTaxWithholdingEntry(IntegrationTestCase):
"""
Integration tests for TaxWithholdingEntry.
Use this class for testing interactions between multiple components.
"""
pass

View File

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

View File

@@ -0,0 +1,48 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "field:group_name",
"creation": "2025-06-29 05:24:51.819891",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"group_name"
],
"fields": [
{
"fieldname": "group_name",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Group Name",
"reqd": 1,
"unique": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-06-29 05:25:50.243710",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Tax Withholding Group",
"naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View File

@@ -1,11 +1,11 @@
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors # Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt # For license information, please see license.txt
# import frappe # import frappe
from frappe.model.document import Document from frappe.model.document import Document
class TaxWithheldVouchers(Document): class TaxWithholdingGroup(Document):
# begin: auto-generated types # begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block. # This code is auto-generated. Do not modify anything in this block.
@@ -14,12 +14,7 @@ class TaxWithheldVouchers(Document):
if TYPE_CHECKING: if TYPE_CHECKING:
from frappe.types import DF from frappe.types import DF
parent: DF.Data group_name: DF.Data
parentfield: DF.Data
parenttype: DF.Data
taxable_amount: DF.Currency
voucher_name: DF.Data | None
voucher_type: DF.Data | None
# end: auto-generated types # end: auto-generated types
pass pass

View File

@@ -0,0 +1,20 @@
# 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 IntegrationTestTaxWithholdingGroup(IntegrationTestCase):
"""
Integration tests for TaxWithholdingGroup.
Use this class for testing interactions between multiple components.
"""
pass

View File

@@ -7,10 +7,11 @@
"field_order": [ "field_order": [
"from_date", "from_date",
"to_date", "to_date",
"tax_withholding_rate", "tax_withholding_group",
"column_break_3", "column_break_3",
"single_threshold", "tax_withholding_rate",
"cumulative_threshold" "cumulative_threshold",
"single_threshold"
], ],
"fields": [ "fields": [
{ {
@@ -30,14 +31,14 @@
"fieldname": "single_threshold", "fieldname": "single_threshold",
"fieldtype": "Float", "fieldtype": "Float",
"in_list_view": 1, "in_list_view": 1,
"label": "Single Transaction Threshold" "label": "Transaction Threshold"
}, },
{ {
"columns": 3, "columns": 3,
"fieldname": "cumulative_threshold", "fieldname": "cumulative_threshold",
"fieldtype": "Float", "fieldtype": "Float",
"in_list_view": 1, "in_list_view": 1,
"label": "Cumulative Transaction Threshold" "label": "Cumulative Threshold"
}, },
{ {
"columns": 2, "columns": 2,
@@ -54,20 +55,28 @@
"in_list_view": 1, "in_list_view": 1,
"label": "To Date", "label": "To Date",
"reqd": 1 "reqd": 1
},
{
"fieldname": "tax_withholding_group",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Tax Withholding Group",
"options": "Tax Withholding Group"
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2024-03-27 13:10:52.708165", "modified": "2025-06-29 05:31:05.120377",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Tax Withholding Rate", "name": "Tax Withholding Rate",
"owner": "Administrator", "owner": "Administrator",
"permissions": [], "permissions": [],
"quick_entry": 1, "quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "creation", "sort_field": "creation",
"sort_order": "DESC", "sort_order": "DESC",
"states": [], "states": [],
"track_changes": 1 "track_changes": 1
} }

View File

@@ -20,6 +20,7 @@ class TaxWithholdingRate(Document):
parentfield: DF.Data parentfield: DF.Data
parenttype: DF.Data parenttype: DF.Data
single_threshold: DF.Float single_threshold: DF.Float
tax_withholding_group: DF.Link | None
tax_withholding_rate: DF.Float tax_withholding_rate: DF.Float
to_date: DF.Date to_date: DF.Date
# end: auto-generated types # end: auto-generated types

View File

@@ -210,19 +210,20 @@ def distribute_gl_based_on_cost_center_allocation(gl_map, precision=None, from_r
for d in gl_map: for d in gl_map:
cost_center = d.get("cost_center") cost_center = d.get("cost_center")
cost_center_allocation = get_cost_center_allocation_data(
gl_map[0]["company"], gl_map[0]["posting_date"], cost_center
)
if not cost_center_allocation:
new_gl_map.append(d)
continue
# Validate budget against main cost center # Validate budget against main cost center
if not from_repost: if not from_repost:
validate_expense_against_budget( validate_expense_against_budget(
d, expense_amount=flt(d.debit, precision) - flt(d.credit, precision) d, expense_amount=flt(d.debit, precision) - flt(d.credit, precision)
) )
cost_center_allocation = get_cost_center_allocation_data(
gl_map[0]["company"], gl_map[0]["posting_date"], cost_center
)
if not cost_center_allocation:
new_gl_map.append(d)
continue
if d.account == round_off_account: if d.account == round_off_account:
d.cost_center = cost_center_allocation[0][0] d.cost_center = cost_center_allocation[0][0]
new_gl_map.append(d) new_gl_map.append(d)
@@ -406,7 +407,7 @@ def save_entries(gl_map, adv_adj, update_outstanding, from_repost=False):
dimension_filter_map = get_dimension_filter_map() dimension_filter_map = get_dimension_filter_map()
if gl_map: if gl_map:
check_freezing_date(gl_map[0]["posting_date"], adv_adj) check_freezing_date(gl_map[0]["posting_date"], gl_map[0]["company"], adv_adj)
is_opening = any(d.get("is_opening") == "Yes" for d in gl_map) is_opening = any(d.get("is_opening") == "Yes" for d in gl_map)
if gl_map[0]["voucher_type"] != "Period Closing Voucher": if gl_map[0]["voucher_type"] != "Period Closing Voucher":
validate_against_pcv(is_opening, gl_map[0]["posting_date"], gl_map[0]["company"]) validate_against_pcv(is_opening, gl_map[0]["posting_date"], gl_map[0]["company"])
@@ -426,7 +427,11 @@ def make_entry(args, adv_adj, update_outstanding, from_repost=False):
gle.flags.notify_update = False gle.flags.notify_update = False
gle.submit() gle.submit()
if not from_repost and gle.voucher_type != "Period Closing Voucher": if (
not from_repost
and gle.voucher_type != "Period Closing Voucher"
and (gle.is_cancelled == 0 or gle.voucher_type == "Journal Entry")
):
validate_expense_against_budget(args) validate_expense_against_budget(args)
@@ -767,7 +772,7 @@ def make_reverse_gl_entries(
make_entry(new_gle, adv_adj, "Yes") make_entry(new_gle, adv_adj, "Yes")
def check_freezing_date(posting_date, adv_adj=False): def check_freezing_date(posting_date, company, adv_adj=False):
""" """
Nobody can do GL Entries where posting date is before freezing date Nobody can do GL Entries where posting date is before freezing date
except authorized person except authorized person
@@ -776,17 +781,17 @@ def check_freezing_date(posting_date, adv_adj=False):
Hence stop admin to bypass if accounts are freezed Hence stop admin to bypass if accounts are freezed
""" """
if not adv_adj: if not adv_adj:
acc_frozen_upto = frappe.get_single_value("Accounts Settings", "acc_frozen_upto") acc_frozen_till_date = frappe.db.get_value("Company", company, "accounts_frozen_till_date")
if acc_frozen_upto: if acc_frozen_till_date:
frozen_accounts_modifier = frappe.get_single_value( frozen_accounts_modifier = frappe.db.get_value(
"Accounts Settings", "frozen_accounts_modifier" "Company", company, "role_allowed_for_frozen_entries"
) )
if getdate(posting_date) <= getdate(acc_frozen_upto) and ( if getdate(posting_date) <= getdate(acc_frozen_till_date) and (
frozen_accounts_modifier not in frappe.get_roles() or frappe.session.user == "Administrator" frozen_accounts_modifier not in frappe.get_roles() or frappe.session.user == "Administrator"
): ):
frappe.throw( frappe.throw(
_("You are not authorized to add or update entries before {0}").format( _("You are not authorized to add or update entries before {0}").format(
formatdate(acc_frozen_upto) formatdate(acc_frozen_till_date)
) )
) )

View File

@@ -1,108 +1,108 @@
<style> <style>
.letter-head { .letter-head {
border-radius: 18px; border-radius: 18px;
padding-right: 12px; padding-right: 12px;
margin-left: 12px; margin-left: 12px;
margin-right: 12px; margin-right: 12px;
} }
.letter-head td{ .letter-head td {
padding: 0px !important; padding: 0px !important;
} }
.invoice-header { .invoice-header {
width: 100%; width: 100%;
} }
.logo-cell { .logo-cell {
width: 100px; width: 100px;
text-align: center; text-align: center;
position: relative; position: relative;
} }
.logo-container { .logo-container {
width: 90px; width: 90px;
display: block; display: block;
} }
.logo-container img { .logo-container img {
max-width: 90px; max-width: 90px;
max-height: 90px; max-height: 90px;
display: inline-block; display: inline-block;
border-radius: 15px; border-radius: 15px;
} }
.company-details { .company-details {
width: 40%; width: 40%;
align-content: center; align-content: center;
} }
.company-name { .company-name {
font-size: 14px; font-size: 14px;
font-weight: bold; font-weight: bold;
color: #171717; color: #171717;
margin-bottom: 4px; margin-bottom: 4px;
} }
.invoice-info-cell { .invoice-info-cell {
float: right; float: right;
vertical-align: top; vertical-align: top;
} }
.invoice-info { .invoice-info {
margin-bottom: 2px; margin-bottom: 2px;
} }
.invoice-label { .invoice-label {
color: #7C7C7C; color: #7c7c7c;
display: inline-block; display: inline-block;
width: 60px; margin-right: 5px;
margin-right: 5px; }
}
</style> </style>
<table class="invoice-header"> <table class="invoice-header">
<tbody> <tbody>
<tr> <tr>
<td class="logo-cell" style="vertical-align: middle !important;"> <td class="logo-cell" style="vertical-align: middle !important">
<div class="logo-container"> <div class="logo-container">
{% set company_logo = frappe.db.get_value("Company", doc.company, "company_logo") %} {% set company_logo = frappe.db.get_value("Company", doc.company, "company_logo") %} {% if
{% if company_logo %} company_logo %}
<img src="{{ frappe.utils.get_url(company_logo) }}" alt="Company Logo"> <img src="{{ frappe.utils.get_url(company_logo) }}" alt="Company Logo" />
{% endif %} {% endif %}
</div> </div>
</td> </td>
<td class="company-details"> <td class="company-details">
<div class="company-name"> <div class="company-name">{{ doc.company }}</div>
{{ doc.company }} {% if doc.company_address %} {% set company_address = frappe.db.get_value("Address",
</div> doc.company_address, ["address_line1", "address_line2", "city", "state", "pincode",
{% if doc.company_address %} "country"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =
{% set company_address = frappe.db.get_value("Address", doc.company_address, ["address_line1", "address_line2", "city", "state", "pincode", "country"], as_dict=True) %} frappe.db.get_value("Address", doc.billing_address, ["address_line1", "address_line2", "city",
"state", "pincode", "country"], as_dict=True) %} {% endif %} {% if company_address %} {{
company_address.address_line1 or "" }}<br />
{% if company_address.address_line2 %} {{ company_address.address_line2 }}<br />
{% endif %} {{ company_address.city or "" }}, {{ company_address.state or "" }} {{
company_address.pincode or "" }}, {{ company_address.country or ""}}<br />
{% endif %}
</td>
{{ company_address.get("address_line1") or "" }}<br> <td class="invoice-info-cell">
{% if company_address.get("address_line2") %}{{ company_address.get("address_line2") }}<br>{% endif %} {% set website = frappe.db.get_value("Company", doc.company, "website") %} {% set email =
{{ company_address.get("city") or "" }}, {{ company_address.get("state") or "" }} {{ company_address.get("pincode") or "" }}, {{ company_address.get("country") or "" }}<br> frappe.db.get_value("Company", doc.company, "email") %} {% set phone_no =
{% endif %} frappe.db.get_value("Company", doc.company, "phone_no") %}
</td>
<td class="invoice-info-cell"> <div class="invoice-info">
{% set company_details = frappe.db.get_value("Company", doc.company, ["website", "email", "phone_no"], as_dict=True) %} <span class="invoice-label">{{ doc.doctype }}</span>
<span>{{ doc.name }}</span>
<div class="invoice-info"> </div>
<span class="invoice-label">{{ _("Invoice:") }}</span> {% if website %}
<span>{{ doc.name }}</span> <div class="invoice-info">
</div> <span class="invoice-label">{{ _("Website:") }}</span>
{% if company_details.website %} <span>{{ website }}</span>
<div class="invoice-info"> </div>
<span class="invoice-label">{{ _("Website:") }}</span> {% endif %} {% if email %}
<span>{{ company_details.website }}</span> <div class="invoice-info">
</div> <span class="invoice-label">{{ _("Email:") }}</span>
{% endif %} <span>{{ email }}</span>
{% if company_details.email %} </div>
<div class="invoice-info"> {% endif %} {% if phone_no %}
<span class="invoice-label">{{ _("Email:") }}</span> <div class="invoice-info">
<span>{{ company_details.email }}</span> <span class="invoice-label">{{ _("Contact:") }}</span>
</div> <span>{{ phone_no }}</span>
{% endif %} </div>
{% if company_details.phone_no %} {% endif %}
<div class="invoice-info"> </td>
<span class="invoice-label">{{ _("Contact:") }}</span> </tr>
<span>{{ company_details.phone_no }}</span> </tbody>
</div>
{% endif %}
</td>
</tr>
</tbody>
</table> </table>

View File

@@ -1,7 +1,7 @@
<style> <style>
.print-format-preview { .print-format-preview {
margin-top: 12px; margin-top: 12px;
} }
.letter-head { .letter-head {
border-radius: 18px; border-radius: 18px;
background: #f8f8f8; background: #f8f8f8;
@@ -82,41 +82,43 @@
{% endif %} {% endif %}
<div class="company-name">{{ doc.company }}</div> <div class="company-name">{{ doc.company }}</div>
<div class="company-address"> <div class="company-address">
{% if doc.company_address %} {% if doc.company_address %} {% set company_address = frappe.db.get_value("Address",
{% set company_address = frappe.db.get_value("Address", doc.company_address, ["address_line1", "address_line2", "city", "state", "pincode", "country"], as_dict=True) %} doc.company_address, ["address_line1", "address_line2", "city", "state", "pincode",
{{ company_address.address_line1 or "" }}<br /> "country"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =
{% if company_address.address_line2 %} {{ company_address.address_line2 }}<br /> {% endif %} frappe.db.get_value("Address", doc.billing_address, ["address_line1", "address_line2",
{{ company_address.city or "" }}, {{ company_address.state or "" }} "city", "state", "pincode", "country"], as_dict=True) %} {% endif %} {% if company_address
{{ company_address.pincode or "" }}, {{ company_address.country or ""}}<br /> %} {{ company_address.address_line1 or "" }}<br />
{% if company_address.address_line2 %} {{ company_address.address_line2 }}<br />
{% endif %} {{ company_address.city or "" }}, {{ company_address.state or "" }} {{
company_address.pincode or "" }}, {{ company_address.country or ""}}<br />
{% endif %} {% endif %}
</div> </div>
</td> </td>
<td style="vertical-align: top"> <td style="vertical-align: top">
<div style="height: 90px; margin-bottom: 10px; text-align: right"> <div style="height: 90px; margin-bottom: 10px; text-align: right">
<div class="invoice-title">{{ _("Sales Invoice") }}</div> <div class="invoice-title">{{ doc.doctype }}</div>
<div class="invoice-number">{{ doc.name }}</div> <div class="invoice-number">{{ doc.name }}</div>
<br /> <br />
</div> </div>
<div style="text-align: left; float: right" class="other-details"> <div style="text-align: left; float: right" class="other-details">
{% set company_details = frappe.db.get_value("Company", doc.company, ["website", "email", "phone_no"], as_dict=True) %} {% set company_details = frappe.db.get_value("Company", doc.company, ["website", "email",
{% if company_details.website %} "phone_no"], as_dict=True) %} {% set website = company_details.website %} {% set email =
<div> company_details.email %} {% set phone_no = company_details.phone_no %} {% if website %}
<span class="contact-title">{{ _("Website:") }}</span <div>
><span class="contact-value">{{ company_details.website }}</span> <span class="contact-title">{{ _("Website:") }}</span
</div> ><span class="contact-value">{{ website }}</span>
{% endif %} </div>
{% if company_details.email %} {% endif %} {% if email %}
<div> <div>
<span class="contact-title">{{ _("Email:") }}</span <span class="contact-title">{{ _("Email:") }}</span
><span class="contact-value">{{ company_details.email }}</span> ><span class="contact-value">{{ email }}</span>
</div> </div>
{% endif %} {% endif %} {% if phone_no %}
{% if company_details.phone_no %} <div>
<div> <span class="contact-title">{{ _("Contact:") }}</span
<span class="contact-title">{{ _("Contact:") }}</span ><span class="contact-value">{{ phone_no }}</span>
><span class="contact-value">{{ company_details.phone_no }}</span> </div>
</div>
{% endif %} {% endif %}
</div> </div>
</td> </td>

View File

@@ -176,10 +176,6 @@ def _get_party_details(
for d in party.get("sales_team") for d in party.get("sales_team")
] ]
# supplier tax withholding category
if party_type == "Supplier" and party:
party_details["supplier_tds"] = frappe.get_value(party_type, party.name, "tax_withholding_category")
if not party_details.get("tax_category") and pos_profile: if not party_details.get("tax_category") and pos_profile:
party_details["tax_category"] = frappe.get_value("POS Profile", pos_profile, "tax_category") party_details["tax_category"] = frappe.get_value("POS Profile", pos_profile, "tax_category")
@@ -352,10 +348,13 @@ def set_contact_details(party_details, party, party_type):
def set_other_values(party_details, party, party_type): def set_other_values(party_details, party, party_type):
# copy # copy
to_copy = ["tax_withholding_category", "tax_withholding_group", "language"]
if party_type == "Customer": if party_type == "Customer":
to_copy = ["customer_name", "customer_group", "territory", "language"] to_copy.extend(["customer_name", "customer_group", "territory"])
else: else:
to_copy = ["supplier_name", "supplier_group", "language"] to_copy.extend(["supplier_name", "supplier_group"])
for f in to_copy: for f in to_copy:
party_details[f] = party.get(f) party_details[f] = party.get(f)
@@ -795,7 +794,7 @@ def get_payment_terms_template(party_name, party_type, company=None):
return template return template
def validate_party_frozen_disabled(party_type, party_name): def validate_party_frozen_disabled(company, party_type, party_name):
if frappe.flags.ignore_party_validation: if frappe.flags.ignore_party_validation:
return return
@@ -805,10 +804,10 @@ def validate_party_frozen_disabled(party_type, party_name):
if party.disabled: if party.disabled:
frappe.throw(_("{0} {1} is disabled").format(party_type, party_name), PartyDisabled) frappe.throw(_("{0} {1} is disabled").format(party_type, party_name), PartyDisabled)
elif party.get("is_frozen"): elif party.get("is_frozen"):
frozen_accounts_modifier = frappe.get_single_value( role_allowed_for_frozen_entries = frappe.get_cached_value(
"Accounts Settings", "frozen_accounts_modifier" "Company", company, "role_allowed_for_frozen_entries"
) )
if frozen_accounts_modifier not in frappe.get_roles(): if role_allowed_for_frozen_entries not in frappe.get_roles():
frappe.throw(_("{0} {1} is frozen").format(party_type, party_name), PartyFrozen) frappe.throw(_("{0} {1} is frozen").format(party_type, party_name), PartyFrozen)
elif party_type == "Employee": elif party_type == "Employee":

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1384,27 +1384,14 @@ class InitSQLProceduresForAR:
amount_in_account_currency {_currency_type}) engine=memory; amount_in_account_currency {_currency_type}) engine=memory;
""" """
# Function
genkey_function_name = "ar_genkey"
genkey_function_sql = f"""
create function `{genkey_function_name}`(rec row type of `{_row_def_table_name}`, allocate bool) returns char(40)
begin
if allocate then
return sha1(concat_ws(',', rec.account, rec.against_voucher_type, rec.against_voucher_no, rec.party));
else
return sha1(concat_ws(',', rec.account, rec.voucher_type, rec.voucher_no, rec.party));
end if;
end
"""
# Procedures # Procedures
init_procedure_name = "ar_init_tmp_table" init_procedure_name = "ar_init_tmp_table"
init_procedure_sql = f""" init_procedure_sql = f"""
create procedure ar_init_tmp_table(in ple row type of `{_row_def_table_name}`) create procedure ar_init_tmp_table(in ple row type of `{_row_def_table_name}`)
begin begin
if not exists (select name from `{_voucher_balance_name}` where name = `{genkey_function_name}`(ple, false)) if not exists (select name from `{_voucher_balance_name}` where name = sha1(concat_ws(',', ple.account, ple.against_voucher_type, ple.against_voucher_no, ple.party)))
then then
insert into `{_voucher_balance_name}` values (`{genkey_function_name}`(ple, false), ple.voucher_type, ple.voucher_no, ple.party, ple.account, ple.posting_date, ple.account_currency, ple.cost_center, 0, 0, 0, 0, 0, 0); insert into `{_voucher_balance_name}` values (sha1(concat_ws(',', ple.account, ple.against_voucher_type, ple.against_voucher_no, ple.party)), ple.voucher_type, ple.voucher_no, ple.party, ple.account, ple.posting_date, ple.account_currency, ple.cost_center, 0, 0, 0, 0, 0, 0);
end if; end if;
end; end;
""" """
@@ -1446,16 +1433,13 @@ class InitSQLProceduresForAR:
end if; end if;
insert into `{_voucher_balance_name}` values (`{genkey_function_name}`(ple, true), ple.against_voucher_type, ple.against_voucher_no, ple.party, ple.account, ple.posting_date, ple.account_currency,'', invoiced, paid, 0, invoiced_in_account_currency, paid_in_account_currency, 0); insert into `{_voucher_balance_name}` values (sha1(concat_ws(',', ple.account, ple.voucher_type, ple.voucher_no, ple.party)), ple.against_voucher_type, ple.against_voucher_no, ple.party, ple.account, ple.posting_date, ple.account_currency,'', invoiced, paid, 0, invoiced_in_account_currency, paid_in_account_currency, 0);
end; end;
""" """
def __init__(self): def __init__(self):
existing_procedures = frappe.db.get_routines() existing_procedures = frappe.db.get_routines()
if self.genkey_function_name not in existing_procedures:
frappe.db.sql(self.genkey_function_sql)
if self.init_procedure_name not in existing_procedures: if self.init_procedure_name not in existing_procedures:
frappe.db.sql(self.init_procedure_sql) frappe.db.sql(self.init_procedure_sql)

View File

@@ -69,12 +69,18 @@ class PartyLedgerSummaryReport:
party_type = self.filters.party_type party_type = self.filters.party_type
doctype = qb.DocType(party_type) doctype = qb.DocType(party_type)
party_details_fields = [
doctype.name.as_("party"),
f"{scrub(party_type)}_name",
f"{scrub(party_type)}_group",
]
if party_type == "Customer":
party_details_fields.append(doctype.territory)
conditions = self.get_party_conditions(doctype) conditions = self.get_party_conditions(doctype)
query = ( query = qb.from_(doctype).select(*party_details_fields).where(Criterion.all(conditions))
qb.from_(doctype)
.select(doctype.name.as_("party"), f"{scrub(party_type)}_name")
.where(Criterion.all(conditions))
)
from frappe.desk.reportview import build_match_conditions from frappe.desk.reportview import build_match_conditions
@@ -153,6 +159,31 @@ class PartyLedgerSummaryReport:
credit_or_debit_note = "Credit Note" if self.filters.party_type == "Customer" else "Debit Note" credit_or_debit_note = "Credit Note" if self.filters.party_type == "Customer" else "Debit Note"
if self.filters.party_type == "Customer":
columns += [
{
"label": _("Customer Group"),
"fieldname": "customer_group",
"fieldtype": "Link",
"options": "Customer Group",
},
{
"label": _("Territory"),
"fieldname": "territory",
"fieldtype": "Link",
"options": "Territory",
},
]
else:
columns += [
{
"label": _("Supplier Group"),
"fieldname": "supplier_group",
"fieldtype": "Link",
"options": "Supplier Group",
}
]
columns += [ columns += [
{ {
"label": _("Opening Balance"), "label": _("Opening Balance"),
@@ -214,35 +245,6 @@ class PartyLedgerSummaryReport:
}, },
] ]
# Hidden columns for handling 'User Permissions'
if self.filters.party_type == "Customer":
columns += [
{
"label": _("Territory"),
"fieldname": "territory",
"fieldtype": "Link",
"options": "Territory",
"hidden": 1,
},
{
"label": _("Customer Group"),
"fieldname": "customer_group",
"fieldtype": "Link",
"options": "Customer Group",
"hidden": 1,
},
]
else:
columns += [
{
"label": _("Supplier Group"),
"fieldname": "supplier_group",
"fieldtype": "Link",
"options": "Supplier Group",
"hidden": 1,
}
]
columns.append({"label": _("Dr/Cr"), "fieldname": "dr_or_cr", "fieldtype": "Data", "width": 100}) columns.append({"label": _("Dr/Cr"), "fieldname": "dr_or_cr", "fieldtype": "Data", "width": 100})
return columns return columns

View File

@@ -186,6 +186,8 @@ class TestCustomerLedgerSummary(AccountsTestMixin, IntegrationTestCase):
expected = { expected = {
"party": "_Test Customer", "party": "_Test Customer",
"customer_name": "_Test Customer", "customer_name": "_Test Customer",
"customer_group": "_Test Customer Group",
"territory": "_Test Territory",
"party_name": "_Test Customer", "party_name": "_Test Customer",
"opening_balance": 0, "opening_balance": 0,
"invoiced_amount": 100.0, "invoiced_amount": 100.0,
@@ -213,6 +215,8 @@ class TestCustomerLedgerSummary(AccountsTestMixin, IntegrationTestCase):
expected = { expected = {
"party": "_Test Customer", "party": "_Test Customer",
"customer_name": "_Test Customer", "customer_name": "_Test Customer",
"customer_group": "_Test Customer Group",
"territory": "_Test Territory",
"party_name": "_Test Customer", "party_name": "_Test Customer",
"opening_balance": 0, "opening_balance": 0,
"invoiced_amount": 100.0, "invoiced_amount": 100.0,

View File

@@ -5,7 +5,6 @@
import frappe import frappe
from frappe import _ from frappe import _
from frappe.utils import flt from frappe.utils import flt
from pypika import Order
import erpnext import erpnext
from erpnext.accounts.report.item_wise_sales_register.item_wise_sales_register import ( from erpnext.accounts.report.item_wise_sales_register.item_wise_sales_register import (
@@ -41,16 +40,6 @@ def _execute(filters=None, additional_table_columns=None):
tax_doctype="Purchase Taxes and Charges", tax_doctype="Purchase Taxes and Charges",
) )
scrubbed_tax_fields = {}
for tax in tax_columns:
scrubbed_tax_fields.update(
{
tax + " Rate": frappe.scrub(tax + " Rate"),
tax + " Amount": frappe.scrub(tax + " Amount"),
}
)
po_pr_map = get_purchase_receipts_against_purchase_order(item_list) po_pr_map = get_purchase_receipts_against_purchase_order(item_list)
data = [] data = []
@@ -100,8 +89,8 @@ def _execute(filters=None, additional_table_columns=None):
for tax, details in itemised_tax.get(d.name, {}).items(): for tax, details in itemised_tax.get(d.name, {}).items():
row.update( row.update(
{ {
scrubbed_tax_fields[tax + " Rate"]: details.get("tax_rate", 0), f"{tax}_rate": details.get("tax_rate", 0),
scrubbed_tax_fields[tax + " Amount"]: details.get("tax_amount", 0), f"{tax}_amount": details.get("tax_amount", 0),
} }
) )
if details.get("is_other_charges"): if details.get("is_other_charges"):

View File

@@ -5,7 +5,7 @@
import frappe import frappe
from frappe import _ from frappe import _
from frappe.query_builder import functions as fn from frappe.query_builder import functions as fn
from frappe.utils import cstr, flt from frappe.utils import flt
from frappe.utils.nestedset import get_descendants_of from frappe.utils.nestedset import get_descendants_of
from frappe.utils.xlsxutils import handle_html from frappe.utils.xlsxutils import handle_html
@@ -32,15 +32,6 @@ def _execute(filters=None, additional_table_columns=None, additional_conditions=
return columns, [], None, None, None, 0 return columns, [], None, None, None, 0
itemised_tax, tax_columns = get_tax_accounts(item_list, columns, company_currency) itemised_tax, tax_columns = get_tax_accounts(item_list, columns, company_currency)
scrubbed_tax_fields = {}
for tax in tax_columns:
scrubbed_tax_fields.update(
{
tax + " Rate": frappe.scrub(tax + " Rate"),
tax + " Amount": frappe.scrub(tax + " Amount"),
}
)
mode_of_payments = get_mode_of_payments(set(d.parent for d in item_list)) mode_of_payments = get_mode_of_payments(set(d.parent for d in item_list))
so_dn_map = get_delivery_notes_against_sales_order(item_list) so_dn_map = get_delivery_notes_against_sales_order(item_list)
@@ -101,8 +92,8 @@ def _execute(filters=None, additional_table_columns=None, additional_conditions=
for tax, details in itemised_tax.get(d.name, {}).items(): for tax, details in itemised_tax.get(d.name, {}).items():
row.update( row.update(
{ {
scrubbed_tax_fields[tax + " Rate"]: details.get("tax_rate", 0), f"{tax}_rate": details.get("tax_rate", 0),
scrubbed_tax_fields[tax + " Amount"]: details.get("tax_amount", 0), f"{tax}_amount": details.get("tax_amount", 0),
} }
) )
if details.get("is_other_charges"): if details.get("is_other_charges"):
@@ -567,15 +558,24 @@ def get_tax_accounts(
tax_details = query.run(as_dict=True) tax_details = query.run(as_dict=True)
precision = frappe.get_precision(tax_doctype, "tax_amount", currency=company_currency) or 2 precision = frappe.get_precision(tax_doctype, "tax_amount", currency=company_currency) or 2
tax_columns = set() tax_columns = {}
itemised_tax = {} itemised_tax = {}
scrubbed_description_map = {}
for row in tax_details: for row in tax_details:
description = handle_html(row.description) or row.account_head description = handle_html(row.description) or row.account_head
scrubbed_description = scrubbed_description_map.get(description)
if not scrubbed_description:
scrubbed_description = frappe.scrub(description)
scrubbed_description_map[description] = scrubbed_description
if scrubbed_description not in tax_columns and row.amount:
# as description is text editor earlier and markup can break the column convention in reports
tax_columns[scrubbed_description] = description
rate = "NA" if row.rate == 0 else row.rate rate = "NA" if row.rate == 0 else row.rate
tax_columns.add(description)
itemised_tax.setdefault(row.item_row, {}).setdefault( itemised_tax.setdefault(row.item_row, {}).setdefault(
description, scrubbed_description,
frappe._dict( frappe._dict(
{ {
"tax_rate": rate, "tax_rate": rate,
@@ -585,14 +585,16 @@ def get_tax_accounts(
), ),
) )
itemised_tax[row.item_row][description].tax_amount += flt(row.amount, precision) itemised_tax[row.item_row][scrubbed_description].tax_amount += flt(row.amount, precision)
tax_columns = sorted(tax_columns) tax_columns_list = list(tax_columns.keys())
for desc in tax_columns: tax_columns_list.sort()
for scrubbed_desc in tax_columns_list:
desc = tax_columns[scrubbed_desc]
columns.append( columns.append(
{ {
"label": _(desc + " Rate"), "label": _(desc + " Rate"),
"fieldname": frappe.scrub(desc + " Rate"), "fieldname": f"{scrubbed_desc}_rate",
"fieldtype": "Float", "fieldtype": "Float",
"width": 100, "width": 100,
} }
@@ -601,7 +603,7 @@ def get_tax_accounts(
columns.append( columns.append(
{ {
"label": _(desc + " Amount"), "label": _(desc + " Amount"),
"fieldname": frappe.scrub(desc + " Amount"), "fieldname": f"{scrubbed_desc}_amount",
"fieldtype": "Currency", "fieldtype": "Currency",
"options": "currency", "options": "currency",
"width": 100, "width": 100,
@@ -639,7 +641,7 @@ def get_tax_accounts(
}, },
] ]
return itemised_tax, tax_columns return itemised_tax, tax_columns_list
def get_tax_details_query(doctype, tax_doctype): def get_tax_details_query(doctype, tax_doctype):
@@ -756,5 +758,5 @@ def add_sub_total_row(item, total_row_map, group_by_value, tax_columns):
total_row["percent_gt"] += item["percent_gt"] total_row["percent_gt"] += item["percent_gt"]
for tax in tax_columns: for tax in tax_columns:
total_row.setdefault(frappe.scrub(tax + " Amount"), 0.0) total_row.setdefault(f"{tax}_amount", 0.0)
total_row[frappe.scrub(tax + " Amount")] += flt(item[frappe.scrub(tax + " Amount")]) total_row[f"{tax}_amount"] += flt(item[f"{tax}_amount"])

View File

@@ -14,9 +14,8 @@ frappe.query_reports["Tax Withholding Details"] = {
fieldname: "party_type", fieldname: "party_type",
label: __("Party Type"), label: __("Party Type"),
fieldtype: "Select", fieldtype: "Select",
options: ["Supplier", "Customer"], options: ["", "Supplier", "Customer"],
reqd: 1, default: "",
default: "Supplier",
on_change: function () { on_change: function () {
frappe.query_report.set_filter_value("party", ""); frappe.query_report.set_filter_value("party", "");
}, },

View File

@@ -1,194 +1,112 @@
# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors # Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt # For license information, please see license.txt
import frappe import frappe
from frappe import _ from frappe import _
from frappe.utils import flt, getdate from frappe.query_builder.functions import IfNull
from erpnext.accounts.utils import get_currency_precision
def execute(filters=None): def execute(filters=None):
if filters.get("party_type") == "Customer": """Generate Tax Withholding Details report"""
party_naming_by = frappe.get_single_value("Selling Settings", "cust_master_name")
else:
party_naming_by = frappe.db.get_single_value("Buying Settings", "supp_master_name")
filters["naming_series"] = party_naming_by
validate_filters(filters) validate_filters(filters)
(
tds_docs,
tds_accounts,
tax_category_map,
journal_entry_party_map,
net_total_map,
) = get_tds_docs(filters)
# Process and format data
data = get_tax_withholding_data(filters)
columns = get_columns(filters) columns = get_columns(filters)
res = get_result( return columns, data
filters, tds_docs, tds_accounts, tax_category_map, journal_entry_party_map, net_total_map
)
return columns, res
def validate_filters(filters): def validate_filters(filters):
"""Validate if dates are properly set""" """Validate report filters"""
filters = frappe._dict(filters or {}) filters = frappe._dict(filters or {})
if not filters.from_date or not filters.to_date:
frappe.throw(_("From Date and To Date are required"))
if filters.from_date > filters.to_date: if filters.from_date > filters.to_date:
frappe.throw(_("From Date must be before To Date")) frappe.throw(_("From Date must be before To Date"))
def get_result(filters, tds_docs, tds_accounts, tax_category_map, journal_entry_party_map, net_total_map): def get_tax_withholding_data(filters):
party_map = get_party_pan_map(filters.get("party_type")) """Process entries into final report format"""
tax_rate_map = get_tax_rate_map(filters) data = []
gle_map = get_gle_map(tds_docs) entries = get_tax_withholding_entries(filters)
precision = get_currency_precision() if not entries:
return data
out = [] doc_info = get_additional_doc_info(entries)
entries = {} party_details = get_party_details(entries)
for name, details in gle_map.items():
for entry in details:
tax_amount, total_amount, grand_total, base_total = 0, 0, 0, 0
tax_withholding_category, rate = None, None
bill_no, bill_date = "", ""
party = entry.party or entry.against
posting_date = entry.posting_date
voucher_type = entry.voucher_type
if voucher_type == "Journal Entry": for entry in entries:
party_list = journal_entry_party_map.get(name) doc_details = frappe._dict()
if party_list: if entry.taxable_name:
party = party_list[0] doc_details = doc_info.get((entry.taxable_doctype, entry.taxable_name), {})
if entry.account in tds_accounts.keys(): party_info = party_details.get((entry.party_type, entry.party), {})
tax_amount += entry.credit - entry.debit
# infer tax withholding category from the account if it's the single account for this category
tax_withholding_category = tds_accounts.get(entry.account)
# or else the consolidated value from the voucher document
if not tax_withholding_category:
tax_withholding_category = tax_category_map.get((voucher_type, name))
# or else from the party default
if not tax_withholding_category:
tax_withholding_category = party_map.get(party, {}).get("tax_withholding_category")
rate = get_tax_withholding_rates(tax_rate_map.get(tax_withholding_category, []), posting_date) row = {
"section_code": entry.tax_withholding_category,
"entity_type": party_info.get("entity_type"),
"rate": entry.tax_rate,
"total_amount": entry.taxable_amount,
"grand_total": doc_details.get("grand_total", 0),
"base_total": doc_details.get("base_total", 0),
"tax_amount": entry.withholding_amount,
"transaction_date": entry.withholding_date,
"transaction_type": entry.taxable_doctype,
"ref_no": entry.taxable_name,
"taxable_date": entry.taxable_date,
"supplier_invoice_no": doc_details.get("bill_no"),
"supplier_invoice_date": doc_details.get("bill_date"),
"withholding_doctype": entry.withholding_doctype,
"withholding_name": entry.withholding_name,
"party_name": party_info.get("party_name"),
"tax_id": entry.tax_id,
"party": entry.party,
"party_type": entry.party_type,
}
data.append(row)
values = net_total_map.get((voucher_type, name)) # Sort by section code and transaction date
data.sort(key=lambda x: (x["section_code"] or "", x["transaction_date"] or ""))
if values: return data
if voucher_type == "Journal Entry" and tax_amount and rate:
# back calculate total amount from rate and tax_amount
base_total = min(flt(tax_amount / (rate / 100), precision=precision), values[0])
total_amount = grand_total = base_total
else:
if tax_amount and rate:
# back calculate total amount from rate and tax_amount
total_amount = flt((tax_amount * 100) / rate, precision=precision)
else:
total_amount = values[0]
grand_total = values[1]
base_total = values[2]
if voucher_type == "Purchase Invoice":
bill_no = values[3]
bill_date = values[4]
else:
total_amount += entry.credit
if tax_amount:
if party_map.get(party, {}).get("party_type") == "Supplier":
party_name = "supplier_name"
party_type = "supplier_type"
else:
party_name = "customer_name"
party_type = "customer_type"
row = {
"pan" if frappe.db.has_column(filters.party_type, "pan") else "tax_id": party_map.get(
party, {}
).get("pan"),
"party": party_map.get(party, {}).get("name"),
}
if filters.naming_series == "Naming Series":
row["party_name"] = party_map.get(party, {}).get(party_name)
row.update(
{
"section_code": tax_withholding_category or "",
"entity_type": party_map.get(party, {}).get(party_type),
"rate": rate,
"total_amount": total_amount,
"grand_total": grand_total,
"base_total": base_total,
"tax_amount": tax_amount,
"transaction_date": posting_date,
"transaction_type": voucher_type,
"ref_no": name,
"supplier_invoice_no": bill_no,
"supplier_invoice_date": bill_date,
}
)
key = entry.voucher_no
if key in entries:
entries[key]["tax_amount"] += tax_amount
else:
entries[key] = row
out = list(entries.values())
out.sort(key=lambda x: (x["section_code"], x["transaction_date"]))
return out
def get_party_pan_map(party_type): def get_party_details(entries):
"""Fetch party details in batch for all entries"""
party_map = frappe._dict() party_map = frappe._dict()
parties_by_type = {"Customer": set(), "Supplier": set()}
fields = ["name", "tax_withholding_category"] # Group parties by type
if party_type == "Supplier": for entry in entries:
fields += ["supplier_type", "supplier_name"] if entry.party_type in parties_by_type and entry.party:
else: parties_by_type[entry.party_type].add(entry.party)
fields += ["customer_type", "customer_name"]
if frappe.db.has_column(party_type, "pan"): # Batch fetch for each party type
fields.append("pan") for party_type, party_set in parties_by_type.items():
if not party_type or not party_set:
continue
party_details = frappe.db.get_all(party_type, fields=fields) doctype = frappe.qb.DocType(party_type)
fields = [doctype.name]
for party in party_details: if party_type == "Supplier":
party.party_type = party_type fields.extend([doctype.supplier_type.as_("entity_type"), doctype.supplier_name.as_("party_name")])
party_map[party.name] = party elif party_type == "Customer":
fields.extend([doctype.customer_type.as_("entity_type"), doctype.customer_name.as_("party_name")])
query = frappe.qb.from_(doctype).select(*fields).where(doctype.name.isin(party_set))
party_details = query.run(as_dict=True)
for party in party_details:
party_map[(party_type, party.name)] = party
return party_map return party_map
def get_gle_map(documents):
# create gle_map of the form
# {"purchase_invoice": list of dict of all gle created for this invoice}
gle_map = {}
gle = frappe.db.get_all(
"GL Entry",
{"voucher_no": ["in", documents], "is_cancelled": 0},
["credit", "debit", "account", "voucher_no", "posting_date", "voucher_type", "against", "party"],
)
for d in gle:
if d.voucher_no not in gle_map:
gle_map[d.voucher_no] = [d]
else:
gle_map[d.voucher_no].append(d)
return gle_map
def get_columns(filters): def get_columns(filters):
pan = "pan" if frappe.db.has_column(filters.party_type, "pan") else "tax_id" """Generate report columns based on filters"""
columns = [ columns = [
{ {
"label": _("Section Code"), "label": _("Section Code"),
@@ -197,286 +115,190 @@ def get_columns(filters):
"fieldtype": "Link", "fieldtype": "Link",
"width": 90, "width": 90,
}, },
{"label": _(frappe.unscrub(pan)), "fieldname": pan, "fieldtype": "Data", "width": 60}, {"label": _("Tax Id"), "fieldname": "tax_id", "fieldtype": "Data", "width": 60},
{
"label": _(f"{filters.get('party_type', 'Party')} Name"),
"fieldname": "party_name",
"fieldtype": "Data",
"width": 180,
},
{
"label": _(filters.get("party_type", "Party")),
"fieldname": "party",
"fieldtype": "Dynamic Link",
"options": "party_type",
"width": 180,
},
{
"label": _("Entity Type"),
"fieldname": "entity_type",
"fieldtype": "Data",
"width": 100,
},
{
"label": _("Supplier Invoice No"),
"fieldname": "supplier_invoice_no",
"fieldtype": "Data",
"width": 120,
},
{
"label": _("Supplier Invoice Date"),
"fieldname": "supplier_invoice_date",
"fieldtype": "Date",
"width": 120,
},
{
"label": _("Tax Rate %"),
"fieldname": "rate",
"fieldtype": "Percent",
"width": 60,
},
{
"label": _("Total Amount"),
"fieldname": "total_amount",
"fieldtype": "Currency",
"width": 120,
},
{
"label": _("Base Total"),
"fieldname": "base_total",
"fieldtype": "Currency",
"width": 120,
},
{
"label": _("Tax Amount"),
"fieldname": "tax_amount",
"fieldtype": "Currency",
"width": 120,
},
{
"label": _("Grand Total"),
"fieldname": "grand_total",
"fieldtype": "Currency",
"width": 120,
},
{
"label": _("Reference Date"),
"fieldname": "taxable_date",
"fieldtype": "Date",
"width": 100,
},
{
"label": _("Transaction Type"),
"fieldname": "transaction_type",
"fieldtype": "Data",
"width": 130,
},
{
"label": _("Reference No."),
"fieldname": "ref_no",
"fieldtype": "Dynamic Link",
"options": "transaction_type",
"width": 180,
},
{
"label": _("Date of Transaction"),
"fieldname": "transaction_date",
"fieldtype": "Date",
"width": 100,
},
{
"label": _("Withholding Document"),
"fieldname": "withholding_name",
"fieldtype": "Dynamic Link",
"options": "withholding_doctype",
"width": 150,
},
] ]
if filters.naming_series == "Naming Series":
columns.append(
{
"label": _(filters.party_type + " Name"),
"fieldname": "party_name",
"fieldtype": "Data",
"width": 180,
}
)
else:
columns.append(
{
"label": _(filters.get("party_type")),
"fieldname": "party",
"fieldtype": "Dynamic Link",
"options": "party_type",
"width": 180,
}
)
columns.extend(
[
{"label": _("Entity Type"), "fieldname": "entity_type", "fieldtype": "Data", "width": 100},
]
)
if filters.party_type == "Supplier":
columns.extend(
[
{
"label": _("Supplier Invoice No"),
"fieldname": "supplier_invoice_no",
"fieldtype": "Data",
"width": 120,
},
{
"label": _("Supplier Invoice Date"),
"fieldname": "supplier_invoice_date",
"fieldtype": "Date",
"width": 120,
},
]
)
columns.extend(
[
{
"label": _("TDS Rate %") if filters.get("party_type") == "Supplier" else _("TCS Rate %"),
"fieldname": "rate",
"fieldtype": "Percent",
"width": 60,
},
{
"label": _("Total Amount"),
"fieldname": "total_amount",
"fieldtype": "Float",
"width": 120,
},
{
"label": _("Base Total"),
"fieldname": "base_total",
"fieldtype": "Float",
"width": 120,
},
{
"label": _("TDS Amount") if filters.get("party_type") == "Supplier" else _("TCS Amount"),
"fieldname": "tax_amount",
"fieldtype": "Float",
"width": 120,
},
{
"label": _("Grand Total"),
"fieldname": "grand_total",
"fieldtype": "Float",
"width": 120,
},
{"label": _("Transaction Type"), "fieldname": "transaction_type", "width": 130},
{
"label": _("Reference No."),
"fieldname": "ref_no",
"fieldtype": "Dynamic Link",
"options": "transaction_type",
"width": 180,
},
{
"label": _("Date of Transaction"),
"fieldname": "transaction_date",
"fieldtype": "Date",
"width": 100,
},
]
)
return columns return columns
def get_tds_docs(filters): def get_tax_withholding_entries(filters):
tds_documents = [] twe = frappe.qb.DocType("Tax Withholding Entry")
purchase_invoices = []
sales_invoices = []
payment_entries = []
journal_entries = []
tax_category_map = frappe._dict()
net_total_map = frappe._dict()
journal_entry_party_map = frappe._dict()
bank_accounts = frappe.get_all("Account", {"is_group": 0, "account_type": "Bank"}, pluck="name")
_tds_accounts = frappe.get_all(
"Tax Withholding Account",
{"company": filters.get("company")},
["account", "parent"],
)
tds_accounts = {}
for tds_acc in _tds_accounts:
# if it turns out not to be the only tax withholding category, then don't include in the map
if tds_acc["account"] in tds_accounts:
tds_accounts[tds_acc["account"]] = None
else:
tds_accounts[tds_acc["account"]] = tds_acc["parent"]
tds_docs = get_tds_docs_query(filters, bank_accounts, list(tds_accounts.keys())).run(as_dict=True)
for d in tds_docs:
if d.voucher_type == "Purchase Invoice":
purchase_invoices.append(d.voucher_no)
if d.voucher_type == "Sales Invoice":
sales_invoices.append(d.voucher_no)
elif d.voucher_type == "Payment Entry":
payment_entries.append(d.voucher_no)
elif d.voucher_type == "Journal Entry":
journal_entries.append(d.voucher_no)
tds_documents.append(d.voucher_no)
if purchase_invoices:
get_doc_info(purchase_invoices, "Purchase Invoice", tax_category_map, net_total_map)
if sales_invoices:
get_doc_info(sales_invoices, "Sales Invoice", tax_category_map, net_total_map)
if payment_entries:
get_doc_info(payment_entries, "Payment Entry", tax_category_map, net_total_map)
if journal_entries:
journal_entry_party_map = get_journal_entry_party_map(journal_entries)
get_doc_info(journal_entries, "Journal Entry", tax_category_map, net_total_map)
return (
tds_documents,
tds_accounts,
tax_category_map,
journal_entry_party_map,
net_total_map,
)
def get_tds_docs_query(filters, bank_accounts, tds_accounts):
if not tds_accounts:
frappe.throw(
_("No {0} Accounts found for this company.").format(frappe.bold(_("Tax Withholding"))),
title=_("Accounts Missing Error"),
)
gle = frappe.qb.DocType("GL Entry")
query = ( query = (
frappe.qb.from_(gle) frappe.qb.from_(twe)
.select("voucher_no", "voucher_type", "against", "party") .select(
.where(gle.is_cancelled == 0) twe.company,
twe.party_type,
twe.party,
IfNull(twe.tax_id, "").as_("tax_id"),
twe.tax_withholding_category,
IfNull(twe.tax_withholding_group, "").as_("tax_withholding_group"),
twe.taxable_amount,
twe.tax_rate,
twe.withholding_amount,
IfNull(twe.taxable_doctype, "").as_("taxable_doctype"),
IfNull(twe.taxable_name, "").as_("taxable_name"),
twe.taxable_date,
IfNull(twe.under_withheld_reason, "").as_("under_withheld_reason"),
IfNull(twe.lower_deduction_certificate, "").as_("lower_deduction_certificate"),
IfNull(twe.withholding_doctype, "").as_("withholding_doctype"),
IfNull(twe.withholding_name, "").as_("withholding_name"),
twe.withholding_date,
twe.status,
)
.where(twe.docstatus == 1)
.where(twe.withholding_date >= filters.from_date)
.where(twe.withholding_date <= filters.to_date)
.where(IfNull(twe.withholding_name, "") != "")
.where(twe.status != "Duplicate")
) )
if filters.get("from_date"): if filters.get("company"):
query = query.where(gle.posting_date >= filters.get("from_date")) query = query.where(twe.company == filters.get("company"))
if filters.get("to_date"):
query = query.where(gle.posting_date <= filters.get("to_date")) if filters.get("party_type"):
query = query.where(twe.party_type == filters.get("party_type"))
if filters.get("party"): if filters.get("party"):
party = [filters.get("party")] query = query.where(twe.party == filters.get("party"))
jv_condition = gle.against.isin(party) | (
(gle.voucher_type == "Journal Entry") & (gle.party == filters.get("party"))
)
else:
party = frappe.get_all(filters.get("party_type"), pluck="name")
jv_condition = gle.against.isin(party) | (
(gle.voucher_type == "Journal Entry")
& ((gle.party_type == filters.get("party_type")) | (gle.party_type == ""))
)
query.where((gle.account.isin(tds_accounts) & jv_condition) | gle.party.isin(party)) return query.run(as_dict=True)
if bank_accounts:
query = query.where(
gle.against.notin(bank_accounts) & (gle.account.isin(tds_accounts) & jv_condition)
| gle.party.isin(party)
)
return query
def get_journal_entry_party_map(journal_entries): def get_additional_doc_info(entries):
journal_entry_party_map = {} """Fetch additional document information in batch"""
for d in frappe.db.get_all( doc_info = {}
"Journal Entry Account", docs_by_type = {
{ "Purchase Invoice": set(),
"parent": ("in", journal_entries), "Sales Invoice": set(),
"party_type": ("in", ("Supplier", "Customer")), "Payment Entry": set(),
"party": ("is", "set"), "Journal Entry": set(),
},
["parent", "party"],
):
if d.parent not in journal_entry_party_map:
journal_entry_party_map[d.parent] = []
journal_entry_party_map[d.parent].append(d.party)
return journal_entry_party_map
def get_doc_info(vouchers, doctype, tax_category_map, net_total_map=None):
common_fields = ["name"]
fields_dict = {
"Purchase Invoice": [
"tax_withholding_category",
"base_tax_withholding_net_total",
"grand_total",
"base_total",
"bill_no",
"bill_date",
],
"Sales Invoice": ["base_net_total", "grand_total", "base_total"],
"Payment Entry": [
"tax_withholding_category",
"paid_amount",
"paid_amount_after_tax",
"base_paid_amount",
],
"Journal Entry": ["tax_withholding_category", "total_debit"],
} }
entries = frappe.get_all( # Group documents by type
doctype, filters={"name": ("in", vouchers)}, fields=common_fields + fields_dict[doctype] for entry in entries:
) if entry.taxable_name and entry.taxable_doctype in docs_by_type:
docs_by_type[entry.taxable_doctype].add(entry.taxable_name)
for doctype_name, voucher_set in docs_by_type.items():
if voucher_set:
_fetch_doc_info(doctype_name, voucher_set, doc_info)
return doc_info
def _fetch_doc_info(doctype_name, voucher_set, doc_info):
doctype = frappe.qb.DocType(doctype_name)
fields = [doctype.name]
# Add doctype-specific fields
if doctype_name == "Purchase Invoice":
fields.extend([doctype.grand_total, doctype.base_total, doctype.bill_no, doctype.bill_date])
elif doctype_name == "Sales Invoice":
fields.extend([doctype.grand_total, doctype.base_total])
elif doctype_name == "Payment Entry":
fields.extend(
[doctype.paid_amount_after_tax.as_("grand_total"), doctype.base_paid_amount.as_("base_total")]
)
elif doctype_name == "Journal Entry":
fields.extend([doctype.total_debit.as_("grand_total"), doctype.total_debit.as_("base_total")])
else:
return
query = frappe.qb.from_(doctype).select(*fields).where(doctype.name.isin(voucher_set))
entries = query.run(as_dict=True)
for entry in entries: for entry in entries:
tax_category_map[(doctype, entry.name)] = entry.tax_withholding_category doc_info[(doctype_name, entry.name)] = entry
if doctype == "Purchase Invoice":
value = [
entry.base_tax_withholding_net_total,
entry.grand_total,
entry.base_total,
entry.bill_no,
entry.bill_date,
]
elif doctype == "Sales Invoice":
value = [entry.base_net_total, entry.grand_total, entry.base_total]
elif doctype == "Payment Entry":
value = [entry.paid_amount, entry.paid_amount_after_tax, entry.base_paid_amount]
else:
value = [entry.total_debit] * 3
net_total_map[(doctype, entry.name)] = value
def get_tax_rate_map(filters):
rate_map = frappe.get_all(
"Tax Withholding Rate",
filters={"from_date": ("<=", filters.to_date), "to_date": (">=", filters.from_date)},
fields=["parent", "tax_withholding_rate", "from_date", "to_date"],
)
rate_list = frappe._dict()
for rate in rate_map:
rate_list.setdefault(rate.parent, []).append(frappe._dict(rate))
return rate_list
def get_tax_withholding_rates(tax_withholding, posting_date):
# returns the row that matches with the fiscal year from posting date
for rate in tax_withholding:
if getdate(rate.from_date) <= getdate(posting_date) <= getdate(rate.to_date):
return rate.tax_withholding_rate
return 0

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