chore: resync version-15-hotfix with feature branch

This commit is contained in:
KerollesFathy
2024-12-11 13:41:03 +02:00
parent 5db66bd6dd
commit 6a84f0b027
423 changed files with 23145 additions and 3944 deletions

View File

@@ -4,21 +4,21 @@
# the repo. Unless a later match takes precedence,
erpnext/accounts/ @deepeshgarg007 @ruthra-kumar
erpnext/assets/ @anandbaburajan @deepeshgarg007
erpnext/assets/ @khushi8112 @deepeshgarg007
erpnext/regional @deepeshgarg007 @ruthra-kumar
erpnext/selling @deepeshgarg007 @ruthra-kumar
erpnext/support/ @deepeshgarg007
pos*
erpnext/buying/ @rohitwaghchaure @s-aga-r
erpnext/maintenance/ @rohitwaghchaure @s-aga-r
erpnext/manufacturing/ @rohitwaghchaure @s-aga-r
erpnext/quality_management/ @rohitwaghchaure @s-aga-r
erpnext/stock/ @rohitwaghchaure @s-aga-r
erpnext/subcontracting @rohitwaghchaure @s-aga-r
erpnext/buying/ @rohitwaghchaure
erpnext/maintenance/ @rohitwaghchaure
erpnext/manufacturing/ @rohitwaghchaure
erpnext/quality_management/ @rohitwaghchaure
erpnext/stock/ @rohitwaghchaure
erpnext/subcontracting @rohitwaghchaure
erpnext/controllers/ @deepeshgarg007 @rohitwaghchaure
erpnext/patches/ @deepeshgarg007
.github/ @deepeshgarg007
pyproject.toml @phot0n
pyproject.toml @akhilnarang

View File

@@ -2,8 +2,9 @@ import functools
import inspect
import frappe
from frappe.utils.user import is_website_user
__version__ = "15.28.2"
__version__ = "15.45.4"
def get_default_company(user=None):
@@ -149,3 +150,13 @@ def allow_regional(fn):
return frappe.get_attr(overrides[function_path][-1])(*args, **kwargs)
return caller
def check_app_permission():
if frappe.session.user == "Administrator":
return True
if is_website_user():
return False
return True

View File

@@ -58,7 +58,7 @@ def build_conditions(process_type, account, company):
)
if account:
conditions += f"AND {deferred_account}='{account}'"
conditions += f"AND {deferred_account}={frappe.db.escape(account)}"
elif company:
conditions += f"AND p.company = {frappe.db.escape(company)}"

View File

@@ -121,7 +121,7 @@
"label": "Account Type",
"oldfieldname": "account_type",
"oldfieldtype": "Select",
"options": "\nAccumulated Depreciation\nAsset Received But Not Billed\nBank\nCash\nChargeable\nCapital Work in Progress\nCost of Goods Sold\nCurrent Asset\nCurrent Liability\nDepreciation\nDirect Expense\nDirect Income\nEquity\nExpense Account\nExpenses Included In Asset Valuation\nExpenses Included In Valuation\nFixed Asset\nIncome Account\nIndirect Expense\nIndirect Income\nLiability\nPayable\nReceivable\nRound Off\nStock\nStock Adjustment\nStock Received But Not Billed\nService Received But Not Billed\nTax\nTemporary",
"options": "\nAccumulated Depreciation\nAsset Received But Not Billed\nBank\nCash\nChargeable\nCapital Work in Progress\nCost of Goods Sold\nCurrent Asset\nCurrent Liability\nDepreciation\nDirect Expense\nDirect Income\nEquity\nExpense Account\nExpenses Included In Asset Valuation\nExpenses Included In Valuation\nFixed Asset\nIncome Account\nIndirect Expense\nIndirect Income\nLiability\nPayable\nReceivable\nRound Off\nRound Off for Opening\nStock\nStock Adjustment\nStock Received But Not Billed\nService Received But Not Billed\nTax\nTemporary",
"search_index": 1
},
{
@@ -191,7 +191,7 @@
"idx": 1,
"is_tree": 1,
"links": [],
"modified": "2024-06-27 16:23:04.444354",
"modified": "2024-08-19 15:19:11.095045",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Account",

View File

@@ -60,6 +60,7 @@ class Account(NestedSet):
"Payable",
"Receivable",
"Round Off",
"Round Off for Opening",
"Stock",
"Stock Adjustment",
"Stock Received But Not Billed",
@@ -103,14 +104,12 @@ class Account(NestedSet):
self.name = get_autoname_with_number(self.account_number, self.account_name, self.company)
def validate(self):
from erpnext.accounts.utils import validate_field_number
if frappe.local.flags.allow_unverified_charts:
return
self.validate_parent()
self.validate_parent_child_account_type()
self.validate_root_details()
validate_field_number("Account", self.name, self.account_number, self.company, "account_number")
self.validate_account_number()
self.validate_group_or_ledger()
self.set_root_and_report_type()
self.validate_mandatory()
@@ -202,7 +201,7 @@ class Account(NestedSet):
msg = _(
"There are ledger entries against this account. Changing {0} to non-{1} in live system will cause incorrect output in 'Accounts {2}' report"
).format(
frappe.bold("Account Type"), doc_before_save.account_type, doc_before_save.account_type
frappe.bold(_("Account Type")), doc_before_save.account_type, doc_before_save.account_type
)
frappe.msgprint(msg)
self.add_comment("Comment", msg)
@@ -311,6 +310,22 @@ class Account(NestedSet):
if frappe.db.get_value("GL Entry", {"account": self.name}):
frappe.throw(_("Currency can not be changed after making entries using some other currency"))
def validate_account_number(self, account_number=None):
if not account_number:
account_number = self.account_number
if account_number:
account_with_same_number = frappe.db.get_value(
"Account",
{"account_number": account_number, "company": self.company, "name": ["!=", self.name]},
)
if account_with_same_number:
frappe.throw(
_("Account Number {0} already used in account {1}").format(
account_number, account_with_same_number
)
)
def create_account_for_child_company(self, parent_acc_name_map, descendants, parent_acc_name):
for company in descendants:
company_bold = frappe.bold(company)
@@ -464,19 +479,6 @@ def get_account_autoname(account_number, account_name, company):
return " - ".join(parts)
def validate_account_number(name, account_number, company):
if account_number:
account_with_same_number = frappe.db.get_value(
"Account", {"account_number": account_number, "company": company, "name": ["!=", name]}
)
if account_with_same_number:
frappe.throw(
_("Account Number {0} already used in account {1}").format(
account_number, account_with_same_number
)
)
@frappe.whitelist()
def update_account_number(name, account_name, account_number=None, from_descendant=False):
account = frappe.get_cached_doc("Account", name)
@@ -517,7 +519,7 @@ def update_account_number(name, account_name, account_number=None, from_descenda
frappe.throw(message, title=_("Rename Not Allowed"))
validate_account_number(name, account_number, account.company)
account.validate_account_number(account_number)
if account_number:
frappe.db.set_value("Account", name, "account_number", account_number.strip())
else:

View File

@@ -109,7 +109,8 @@
"Utility Expenses": {},
"Write Off": {},
"Exchange Gain/Loss": {},
"Gain/Loss on Asset Disposal": {}
"Gain/Loss on Asset Disposal": {},
"Impairment": {}
},
"root_type": "Expense"
},
@@ -132,7 +133,8 @@
"Source of Funds (Liabilities)": {
"Capital Account": {
"Reserves and Surplus": {},
"Shareholders Funds": {}
"Shareholders Funds": {},
"Revaluation Surplus": {}
},
"Current Liabilities": {
"Accounts Payable": {

View File

@@ -72,6 +72,7 @@ def get():
_("Write Off"): {},
_("Exchange Gain/Loss"): {},
_("Gain/Loss on Asset Disposal"): {},
_("Impairment"): {},
},
"root_type": "Expense",
},
@@ -104,6 +105,7 @@ def get():
_("Dividends Paid"): {"account_type": "Equity"},
_("Opening Balance Equity"): {"account_type": "Equity"},
_("Retained Earnings"): {"account_type": "Equity"},
_("Revaluation Surplus"): {"account_type": "Equity"},
"root_type": "Equity",
},
}

View File

@@ -113,9 +113,9 @@ def get_previous_closing_entries(company, closing_date, accounting_dimensions):
entries = []
last_period_closing_voucher = frappe.db.get_all(
"Period Closing Voucher",
filters={"docstatus": 1, "company": company, "posting_date": ("<", closing_date)},
filters={"docstatus": 1, "company": company, "period_end_date": ("<", closing_date)},
fields=["name"],
order_by="posting_date desc",
order_by="period_end_date desc",
limit=1,
)

View File

@@ -58,7 +58,7 @@ frappe.ui.form.on("Accounting Dimension", {
},
label: function (frm) {
frm.set_value("fieldname", frappe.model.scrub(frm.doc.label));
frm.set_value("fieldname", frm.doc.label.replace(/ /g, "_").replace(/-/g, "_").toLowerCase());
},
document_type: function (frm) {

View File

@@ -7,6 +7,7 @@ import json
import frappe
from frappe import _, scrub
from frappe.custom.doctype.custom_field.custom_field import create_custom_field
from frappe.database.schema import validate_column_name
from frappe.model import core_doctypes_list
from frappe.model.document import Document
from frappe.utils import cstr
@@ -60,6 +61,7 @@ class AccountingDimension(Document):
if not self.is_new():
self.validate_document_type_change()
validate_column_name(self.fieldname)
self.validate_dimension_defaults()
def validate_document_type_change(self):

View File

@@ -74,12 +74,12 @@ def get_dimension_filter_map():
a.applicable_on_account, d.dimension_value, p.accounting_dimension,
p.allow_or_restrict, a.is_mandatory
FROM
`tabApplicable On Account` a, `tabAllowed Dimension` d,
`tabApplicable On Account` a,
`tabAccounting Dimension Filter` p
LEFT JOIN `tabAllowed Dimension` d ON d.parent = p.name
WHERE
p.name = a.parent
AND p.disabled = 0
AND p.name = d.parent
""",
as_dict=1,
)
@@ -97,7 +97,6 @@ def get_dimension_filter_map():
f.allow_or_restrict,
f.is_mandatory,
)
frappe.flags.dimension_filter_map = dimension_filter_map
return frappe.flags.dimension_filter_map

View File

@@ -101,6 +101,8 @@ def validate_accounting_period_on_doc_save(doc, method=None):
date = doc.available_for_use_date
elif doc.doctype == "Asset Repair":
date = doc.completion_date
elif doc.doctype == "Period Closing Voucher":
date = doc.period_end_date
else:
date = doc.posting_date

View File

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

View File

@@ -0,0 +1,113 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2024-10-16 16:57:12.085072",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"company",
"voucher_type",
"voucher_no",
"against_voucher_type",
"against_voucher_no",
"amount",
"currency",
"event"
],
"fields": [
{
"fieldname": "voucher_type",
"fieldtype": "Link",
"label": "Voucher Type",
"options": "DocType",
"read_only": 1
},
{
"fieldname": "voucher_no",
"fieldtype": "Dynamic Link",
"label": "Voucher No",
"options": "voucher_type",
"read_only": 1
},
{
"fieldname": "against_voucher_type",
"fieldtype": "Link",
"label": "Against Voucher Type",
"options": "DocType",
"read_only": 1
},
{
"fieldname": "against_voucher_no",
"fieldtype": "Dynamic Link",
"label": "Against Voucher No",
"options": "against_voucher_type",
"read_only": 1
},
{
"fieldname": "amount",
"fieldtype": "Currency",
"label": "Amount",
"read_only": 1
},
{
"fieldname": "currency",
"fieldtype": "Link",
"label": "Currency",
"options": "Currency",
"read_only": 1
},
{
"fieldname": "event",
"fieldtype": "Data",
"label": "Event",
"read_only": 1
},
{
"fieldname": "company",
"fieldtype": "Link",
"label": "Company",
"options": "Company",
"read_only": 1
}
],
"in_create": 1,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-11-05 10:31:28.736671",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Advance Payment Ledger Entry",
"owner": "Administrator",
"permissions": [
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Accounts User",
"share": 1
},
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Accounts Manager",
"share": 1
},
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Auditor",
"share": 1
}
],
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View File

@@ -0,0 +1,27 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class AdvancePaymentLedgerEntry(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
against_voucher_no: DF.DynamicLink | None
against_voucher_type: DF.Link | None
amount: DF.Currency
company: DF.Link | None
currency: DF.Link | None
event: DF.Data | None
voucher_no: DF.DynamicLink | None
voucher_type: DF.Link | None
# end: auto-generated types
pass

View File

@@ -0,0 +1,222 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.utils import nowdate, today
from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
class TestAdvancePaymentLedgerEntry(AccountsTestMixin, FrappeTestCase):
"""
Integration tests for AdvancePaymentLedgerEntry.
Use this class for testing interactions between multiple components.
"""
def setUp(self):
self.create_company()
self.create_usd_receivable_account()
self.create_usd_payable_account()
self.create_item()
self.clear_old_entries()
def tearDown(self):
frappe.db.rollback()
def create_sales_order(self, qty=1, rate=100, currency="INR", do_not_submit=False):
"""
Helper method
"""
so = make_sales_order(
company=self.company,
customer=self.customer,
currency=currency,
item=self.item,
qty=qty,
rate=rate,
transaction_date=today(),
do_not_submit=do_not_submit,
)
return so
def create_purchase_order(self, qty=1, rate=100, currency="INR", do_not_submit=False):
"""
Helper method
"""
po = create_purchase_order(
company=self.company,
customer=self.supplier,
currency=currency,
item=self.item,
qty=qty,
rate=rate,
transaction_date=today(),
do_not_submit=do_not_submit,
)
return po
def test_so_advance_paid_and_currency_with_payment(self):
self.create_customer("_Test USD Customer", "USD")
so = self.create_sales_order(currency="USD", do_not_submit=True)
so.conversion_rate = 80
so.submit()
pe_exchange_rate = 85
pe = get_payment_entry(so.doctype, so.name, bank_account=self.cash)
pe.reference_no = "1"
pe.reference_date = nowdate()
pe.paid_from = self.debtors_usd
pe.paid_from_account_currency = "USD"
pe.source_exchange_rate = pe_exchange_rate
pe.paid_amount = so.grand_total
pe.received_amount = pe_exchange_rate * pe.paid_amount
pe.references[0].outstanding_amount = 100
pe.references[0].total_amount = 100
pe.references[0].allocated_amount = 100
pe.save().submit()
so.reload()
self.assertEqual(so.advance_paid, 100)
self.assertEqual(so.party_account_currency, "USD")
# cancel advance payment
pe.reload()
pe.cancel()
so.reload()
self.assertEqual(so.advance_paid, 0)
self.assertEqual(so.party_account_currency, "USD")
def test_so_advance_paid_and_currency_with_journal(self):
self.create_customer("_Test USD Customer", "USD")
so = self.create_sales_order(currency="USD", do_not_submit=True)
so.conversion_rate = 80
so.submit()
je_exchange_rate = 85
je = frappe.get_doc(
{
"doctype": "Journal Entry",
"company": self.company,
"voucher_type": "Journal Entry",
"posting_date": so.transaction_date,
"multi_currency": True,
"accounts": [
{
"account": self.debtors_usd,
"party_type": "Customer",
"party": so.customer,
"credit": 8500,
"credit_in_account_currency": 100,
"is_advance": "Yes",
"reference_type": so.doctype,
"reference_name": so.name,
"exchange_rate": je_exchange_rate,
},
{
"account": self.cash,
"debit": 8500,
"debit_in_account_currency": 8500,
},
],
}
)
je.save().submit()
so.reload()
self.assertEqual(so.advance_paid, 100)
self.assertEqual(so.party_account_currency, "USD")
# cancel advance payment
je.reload()
je.cancel()
so.reload()
self.assertEqual(so.advance_paid, 0)
self.assertEqual(so.party_account_currency, "USD")
def test_po_advance_paid_and_currency_with_payment(self):
self.create_supplier("_Test USD Supplier", "USD")
po = self.create_purchase_order(currency="USD", do_not_submit=True)
po.conversion_rate = 80
po.submit()
pe_exchange_rate = 85
pe = get_payment_entry(po.doctype, po.name, bank_account=self.cash)
pe.reference_no = "1"
pe.reference_date = nowdate()
pe.paid_to = self.creditors_usd
pe.paid_to_account_currency = "USD"
pe.target_exchange_rate = pe_exchange_rate
pe.received_amount = po.grand_total
pe.paid_amount = pe_exchange_rate * pe.received_amount
pe.references[0].outstanding_amount = 100
pe.references[0].total_amount = 100
pe.references[0].allocated_amount = 100
pe.save().submit()
po.reload()
self.assertEqual(po.advance_paid, 100)
self.assertEqual(po.party_account_currency, "USD")
# cancel advance payment
pe.reload()
pe.cancel()
po.reload()
self.assertEqual(po.advance_paid, 0)
self.assertEqual(po.party_account_currency, "USD")
def test_po_advance_paid_and_currency_with_journal(self):
self.create_supplier("_Test USD Supplier", "USD")
po = self.create_purchase_order(currency="USD", do_not_submit=True)
po.conversion_rate = 80
po.submit()
je_exchange_rate = 85
je = frappe.get_doc(
{
"doctype": "Journal Entry",
"company": self.company,
"voucher_type": "Journal Entry",
"posting_date": po.transaction_date,
"multi_currency": True,
"accounts": [
{
"account": self.creditors_usd,
"party_type": "Supplier",
"party": po.supplier,
"debit": 8500,
"debit_in_account_currency": 100,
"is_advance": "Yes",
"reference_type": po.doctype,
"reference_name": po.name,
"exchange_rate": je_exchange_rate,
},
{
"account": self.cash,
"credit": 8500,
"credit_in_account_currency": 8500,
},
],
}
)
je.save().submit()
po.reload()
self.assertEqual(po.advance_paid, 100)
self.assertEqual(po.party_account_currency, "USD")
# cancel advance payment
je.reload()
je.cancel()
po.reload()
self.assertEqual(po.advance_paid, 0)
self.assertEqual(po.party_account_currency, "USD")

View File

@@ -208,8 +208,49 @@
"label": "Disabled"
}
],
"links": [],
"modified": "2023-09-22 21:31:34.763977",
"links": [
{
"group": "Transactions",
"link_doctype": "Payment Request",
"link_fieldname": "bank_account"
},
{
"group": "Transactions",
"link_doctype": "Payment Order",
"link_fieldname": "bank_account"
},
{
"group": "Transactions",
"link_doctype": "Bank Guarantee",
"link_fieldname": "bank_account"
},
{
"group": "Transactions",
"link_doctype": "Bank Transaction",
"link_fieldname": "bank_account"
},
{
"group": "Accounting",
"link_doctype": "Payment Entry",
"link_fieldname": "bank_account"
},
{
"group": "Accounting",
"link_doctype": "Journal Entry",
"link_fieldname": "bank_account"
},
{
"group": "Party",
"link_doctype": "Customer",
"link_fieldname": "default_bank_account"
},
{
"group": "Party",
"link_doctype": "Supplier",
"link_fieldname": "default_bank_account"
}
],
"modified": "2024-10-30 09:41:14.113414",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Bank Account",
@@ -246,4 +287,4 @@
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -1,20 +0,0 @@
from frappe import _
def get_data():
return {
"fieldname": "bank_account",
"non_standard_fieldnames": {
"Customer": "default_bank_account",
"Supplier": "default_bank_account",
},
"transactions": [
{
"label": _("Payments"),
"items": ["Payment Entry", "Payment Request", "Payment Order", "Payroll Entry"],
},
{"label": _("Party"), "items": ["Customer", "Supplier"]},
{"items": ["Bank Guarantee"]},
{"items": ["Journal Entry"]},
],
}

View File

@@ -38,6 +38,11 @@ frappe.ui.form.on("Bank Clearance", {
frm.add_custom_button(__("Get Payment Entries"), () => frm.trigger("get_payment_entries"));
frm.change_custom_button_type(__("Get Payment Entries"), null, "primary");
if (frm.doc.payment_entries.length) {
frm.add_custom_button(__("Update Clearance Date"), () => frm.trigger("update_clearance_date"));
frm.change_custom_button_type(__("Get Payment Entries"), null, "default");
frm.change_custom_button_type(__("Update Clearance Date"), null, "primary");
}
},
update_clearance_date: function (frm) {
@@ -45,13 +50,7 @@ frappe.ui.form.on("Bank Clearance", {
method: "update_clearance_date",
doc: frm.doc,
callback: function (r, rt) {
frm.refresh_field("payment_entries");
frm.refresh_fields();
if (!frm.doc.payment_entries.length) {
frm.change_custom_button_type(__("Get Payment Entries"), null, "primary");
frm.change_custom_button_type(__("Update Clearance Date"), null, "default");
}
frm.refresh();
},
});
},
@@ -60,17 +59,8 @@ frappe.ui.form.on("Bank Clearance", {
return frappe.call({
method: "get_payment_entries",
doc: frm.doc,
callback: function (r, rt) {
frm.refresh_field("payment_entries");
if (frm.doc.payment_entries.length) {
frm.add_custom_button(__("Update Clearance Date"), () =>
frm.trigger("update_clearance_date")
);
frm.change_custom_button_type(__("Get Payment Entries"), null, "default");
frm.change_custom_button_type(__("Update Clearance Date"), null, "primary");
}
callback: function () {
frm.refresh();
},
});
},

View File

@@ -6,7 +6,7 @@ import frappe
from frappe import _, msgprint
from frappe.model.document import Document
from frappe.query_builder.custom import ConstantColumn
from frappe.utils import flt, fmt_money, getdate
from frappe.utils import flt, fmt_money, get_link_to_form, getdate
from pypika import Order
import erpnext
@@ -96,8 +96,11 @@ class BankClearance(Document):
if d.cheque_date and getdate(d.clearance_date) < getdate(d.cheque_date):
frappe.throw(
_("Row #{0}: Clearance date {1} cannot be before Cheque Date {2}").format(
d.idx, d.clearance_date, d.cheque_date
_("Row #{0}: For {1} Clearance date {2} cannot be before Cheque Date {3}").format(
d.idx,
get_link_to_form(d.payment_document, d.payment_entry),
d.clearance_date,
d.cheque_date,
)
)
@@ -105,8 +108,18 @@ class BankClearance(Document):
if not d.clearance_date:
d.clearance_date = None
payment_entry = frappe.get_doc(d.payment_document, d.payment_entry)
payment_entry.db_set("clearance_date", d.clearance_date)
if d.payment_document == "Sales Invoice":
frappe.db.set_value(
"Sales Invoice Payment",
{"parent": d.payment_entry, "account": self.get("account"), "amount": [">", 0]},
"clearance_date",
d.clearance_date,
)
else:
frappe.db.set_value(
d.payment_document, d.payment_entry, "clearance_date", d.clearance_date
)
clearance_date_updated = True
@@ -155,7 +168,7 @@ def get_payment_entries_for_bank_clearance(
"Payment Entry" as payment_document, name as payment_entry,
reference_no as cheque_number, reference_date as cheque_date,
if(paid_from=%(account)s, paid_amount + total_taxes_and_charges, 0) as credit,
if(paid_from=%(account)s, 0, received_amount) as debit,
if(paid_from=%(account)s, 0, received_amount + total_taxes_and_charges) as debit,
posting_date, ifnull(party,if(paid_from=%(account)s,paid_to,paid_from)) as against_account, clearance_date,
if(paid_to=%(account)s, paid_to_account_currency, paid_from_account_currency) as account_currency
from `tabPayment Entry`

View File

@@ -6,16 +6,29 @@ import unittest
import frappe
from frappe.utils import add_months, getdate
from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center
from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.stock.doctype.item.test_item import create_item
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
from erpnext.tests.utils import if_lending_app_installed, if_lending_app_not_installed
class TestBankClearance(unittest.TestCase):
@classmethod
def setUpClass(cls):
create_warehouse(
warehouse_name="_Test Warehouse",
properties={"parent_warehouse": "All Warehouses - _TC"},
company="_Test Company",
)
create_item("_Test Item")
create_cost_center(cost_center_name="_Test Cost Center", company="_Test Company")
clear_payment_entries()
clear_loan_transactions()
clear_pos_sales_invoices()
make_bank_account()
add_transactions()
@@ -83,11 +96,41 @@ class TestBankClearance(unittest.TestCase):
bank_clearance.get_payment_entries()
self.assertEqual(len(bank_clearance.payment_entries), 3)
def test_update_clearance_date_on_si(self):
sales_invoice = make_pos_sales_invoice()
date = getdate()
bank_clearance = frappe.get_doc("Bank Clearance")
bank_clearance.account = "_Test Bank Clearance - _TC"
bank_clearance.from_date = add_months(date, -1)
bank_clearance.to_date = date
bank_clearance.include_pos_transactions = 1
bank_clearance.get_payment_entries()
self.assertNotEqual(len(bank_clearance.payment_entries), 0)
for payment in bank_clearance.payment_entries:
if payment.payment_entry == sales_invoice.name:
payment.clearance_date = date
bank_clearance.update_clearance_date()
si_clearance_date = frappe.db.get_value(
"Sales Invoice Payment",
{"parent": sales_invoice.name, "account": bank_clearance.account},
"clearance_date",
)
self.assertEqual(si_clearance_date, date)
def clear_payment_entries():
frappe.db.delete("Payment Entry")
def clear_pos_sales_invoices():
frappe.db.delete("Sales Invoice", {"is_pos": 1})
@if_lending_app_installed
def clear_loan_transactions():
for dt in [
@@ -115,9 +158,45 @@ def add_transactions():
def make_payment_entry():
pi = make_purchase_invoice(supplier="_Test Supplier", qty=1, rate=690)
from erpnext.buying.doctype.supplier.test_supplier import create_supplier
supplier = create_supplier(supplier_name="_Test Supplier")
pi = make_purchase_invoice(
supplier=supplier,
supplier_warehouse="_Test Warehouse - _TC",
expense_account="Cost of Goods Sold - _TC",
uom="Nos",
qty=1,
rate=690,
)
pe = get_payment_entry("Purchase Invoice", pi.name, bank_account="_Test Bank Clearance - _TC")
pe.reference_no = "Conrad Oct 18"
pe.reference_date = "2018-10-24"
pe.insert()
pe.submit()
def make_pos_sales_invoice():
from erpnext.accounts.doctype.opening_invoice_creation_tool.test_opening_invoice_creation_tool import (
make_customer,
)
mode_of_payment = frappe.get_doc({"doctype": "Mode of Payment", "name": "Cash"})
if not frappe.db.get_value("Mode of Payment Account", {"company": "_Test Company", "parent": "Cash"}):
mode_of_payment.append(
"accounts", {"company": "_Test Company", "default_account": "_Test Bank Clearance - _TC"}
)
mode_of_payment.save()
customer = make_customer(customer="_Test Customer")
si = create_sales_invoice(customer=customer, item="_Test Item", is_pos=1, qty=1, rate=1000, do_not_save=1)
si.set("payments", [])
si.append(
"payments", {"mode_of_payment": "Cash", "account": "_Test Bank Clearance - _TC", "amount": 1000}
)
si.insert()
si.submit()
return si

View File

@@ -208,13 +208,17 @@ class BankTransaction(Document):
if self.party_type and self.party:
return
result = AutoMatchParty(
bank_party_account_number=self.bank_party_account_number,
bank_party_iban=self.bank_party_iban,
bank_party_name=self.bank_party_name,
description=self.description,
deposit=self.deposit,
).match()
result = None
try:
result = AutoMatchParty(
bank_party_account_number=self.bank_party_account_number,
bank_party_iban=self.bank_party_iban,
bank_party_name=self.bank_party_name,
description=self.description,
deposit=self.deposit,
).match()
except Exception:
frappe.log_error(title=_("Error in party matching for Bank Transaction {0}").format(self.name))
if not result:
return

View File

@@ -22,8 +22,10 @@ class TestCostCenterAllocation(unittest.TestCase):
cost_centers = [
"Main Cost Center 1",
"Main Cost Center 2",
"Main Cost Center 3",
"Sub Cost Center 1",
"Sub Cost Center 2",
"Sub Cost Center 3",
]
for cc in cost_centers:
create_cost_center(cost_center_name=cc, company="_Test Company")
@@ -36,7 +38,7 @@ class TestCostCenterAllocation(unittest.TestCase):
)
jv = make_journal_entry(
"_Test Cash - _TC", "Sales - _TC", 100, cost_center="Main Cost Center 1 - _TC", submit=True
"Cash - _TC", "Sales - _TC", 100, cost_center="Main Cost Center 1 - _TC", submit=True
)
expected_values = [["Sub Cost Center 1 - _TC", 0.0, 60], ["Sub Cost Center 2 - _TC", 0.0, 40]]
@@ -120,7 +122,7 @@ class TestCostCenterAllocation(unittest.TestCase):
def test_valid_from_based_on_existing_gle(self):
# GLE posted against Sub Cost Center 1 on today
jv = make_journal_entry(
"_Test Cash - _TC",
"Cash - _TC",
"Sales - _TC",
100,
cost_center="Main Cost Center 1 - _TC",
@@ -141,6 +143,53 @@ class TestCostCenterAllocation(unittest.TestCase):
jv.cancel()
def test_multiple_cost_center_allocation_on_same_main_cost_center(self):
coa1 = create_cost_center_allocation(
"_Test Company",
"Main Cost Center 3 - _TC",
{"Sub Cost Center 1 - _TC": 30, "Sub Cost Center 2 - _TC": 30, "Sub Cost Center 3 - _TC": 40},
valid_from=add_days(today(), -5),
)
coa2 = create_cost_center_allocation(
"_Test Company",
"Main Cost Center 3 - _TC",
{"Sub Cost Center 1 - _TC": 50, "Sub Cost Center 2 - _TC": 50},
valid_from=add_days(today(), -1),
)
jv = make_journal_entry(
"Cash - _TC",
"Sales - _TC",
100,
cost_center="Main Cost Center 3 - _TC",
posting_date=today(),
submit=True,
)
expected_values = {"Sub Cost Center 1 - _TC": 50, "Sub Cost Center 2 - _TC": 50}
gle = frappe.qb.DocType("GL Entry")
gl_entries = (
frappe.qb.from_(gle)
.select(gle.cost_center, gle.debit, gle.credit)
.where(gle.voucher_type == "Journal Entry")
.where(gle.voucher_no == jv.name)
.where(gle.account == "Sales - _TC")
.orderby(gle.cost_center)
).run(as_dict=1)
self.assertTrue(gl_entries)
for gle in gl_entries:
self.assertTrue(gle.cost_center in expected_values)
self.assertEqual(gle.debit, 0)
self.assertEqual(gle.credit, expected_values[gle.cost_center])
coa1.cancel()
coa2.cancel()
jv.cancel()
def create_cost_center_allocation(
company,

View File

@@ -109,7 +109,7 @@ def get_api_endpoint(service_provider: str | None = None, use_http: bool = False
if service_provider == "exchangerate.host":
api = "api.exchangerate.host/convert"
elif service_provider == "frankfurter.app":
api = "frankfurter.app/{transaction_date}"
api = "api.frankfurter.app/{transaction_date}"
protocol = "https://"
if use_http:

View File

@@ -19,16 +19,6 @@
"currency",
"column_break_11",
"conversion_rate",
"address_and_contact_section",
"customer_address",
"address_display",
"contact_person",
"contact_display",
"column_break_16",
"company_address",
"company_address_display",
"contact_mobile",
"contact_email",
"section_break_6",
"dunning_type",
"column_break_8",
@@ -56,7 +46,21 @@
"income_account",
"column_break_48",
"cost_center",
"amended_from"
"amended_from",
"address_and_contact_tab",
"address_and_contact_section",
"customer_address",
"address_display",
"column_break_vodj",
"contact_person",
"contact_display",
"contact_mobile",
"contact_email",
"section_break_xban",
"column_break_16",
"company_address",
"company_address_display",
"column_break_lqmf"
],
"fields": [
{
@@ -178,10 +182,8 @@
"label": "Rate of Interest (%) Yearly"
},
{
"collapsible": 1,
"fieldname": "address_and_contact_section",
"fieldtype": "Section Break",
"label": "Address and Contact"
"fieldtype": "Section Break"
},
{
"fieldname": "address_display",
@@ -377,11 +379,28 @@
{
"fieldname": "column_break_48",
"fieldtype": "Column Break"
},
{
"fieldname": "address_and_contact_tab",
"fieldtype": "Tab Break",
"label": "Address & Contact"
},
{
"fieldname": "column_break_vodj",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_xban",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_lqmf",
"fieldtype": "Column Break"
}
],
"is_submittable": 1,
"links": [],
"modified": "2023-06-15 15:46:53.865712",
"modified": "2024-11-26 13:46:07.760867",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Dunning",
@@ -435,4 +454,4 @@
"states": [],
"title_field": "customer_name",
"track_changes": 1
}
}

View File

@@ -210,19 +210,31 @@ def get_linked_dunnings_as_per_state(sales_invoice, state):
@frappe.whitelist()
def get_dunning_letter_text(dunning_type, doc, language=None):
def get_dunning_letter_text(dunning_type: str, doc: str | dict, language: str | None = None) -> dict:
DOCTYPE = "Dunning Letter Text"
FIELDS = ["body_text", "closing_text", "language"]
if isinstance(doc, str):
doc = json.loads(doc)
if not language:
language = doc.get("language")
if language:
filters = {"parent": dunning_type, "language": language}
else:
filters = {"parent": dunning_type, "is_default_language": 1}
letter_text = frappe.db.get_value(
"Dunning Letter Text", filters, ["body_text", "closing_text", "language"], as_dict=1
)
if letter_text:
return {
"body_text": frappe.render_template(letter_text.body_text, doc),
"closing_text": frappe.render_template(letter_text.closing_text, doc),
"language": letter_text.language,
}
letter_text = frappe.db.get_value(
DOCTYPE, {"parent": dunning_type, "language": language}, FIELDS, as_dict=1
)
if not letter_text:
letter_text = frappe.db.get_value(
DOCTYPE, {"parent": dunning_type, "is_default_language": 1}, FIELDS, as_dict=1
)
if not letter_text:
return {}
return {
"body_text": frappe.render_template(letter_text.body_text, doc),
"closing_text": frappe.render_template(letter_text.closing_text, doc),
"language": letter_text.language,
}

View File

@@ -74,6 +74,21 @@ class ExchangeRateRevaluation(Document):
if not (self.company and self.posting_date):
frappe.throw(_("Please select Company and Posting Date to getting entries"))
def before_submit(self):
self.remove_accounts_without_gain_loss()
def remove_accounts_without_gain_loss(self):
self.accounts = [account for account in self.accounts if account.gain_loss]
if not self.accounts:
frappe.throw(_("At least one account with exchange gain or loss is required"))
frappe.msgprint(
_("Removing rows without exchange gain or loss"),
alert=True,
indicator="yellow",
)
def on_cancel(self):
self.ignore_linked_doctypes = "GL Entry"
@@ -248,23 +263,23 @@ class ExchangeRateRevaluation(Document):
new_exchange_rate = get_exchange_rate(d.account_currency, company_currency, posting_date)
new_balance_in_base_currency = flt(d.balance_in_account_currency * new_exchange_rate)
gain_loss = flt(new_balance_in_base_currency, precision) - flt(d.balance, precision)
if gain_loss:
accounts.append(
{
"account": d.account,
"party_type": d.party_type,
"party": d.party,
"account_currency": d.account_currency,
"balance_in_base_currency": d.balance,
"balance_in_account_currency": d.balance_in_account_currency,
"zero_balance": d.zero_balance,
"current_exchange_rate": current_exchange_rate,
"new_exchange_rate": new_exchange_rate,
"new_balance_in_base_currency": new_balance_in_base_currency,
"new_balance_in_account_currency": d.balance_in_account_currency,
"gain_loss": gain_loss,
}
)
accounts.append(
{
"account": d.account,
"party_type": d.party_type,
"party": d.party,
"account_currency": d.account_currency,
"balance_in_base_currency": d.balance,
"balance_in_account_currency": d.balance_in_account_currency,
"zero_balance": d.zero_balance,
"current_exchange_rate": current_exchange_rate,
"new_exchange_rate": new_exchange_rate,
"new_balance_in_base_currency": new_balance_in_base_currency,
"new_balance_in_account_currency": d.balance_in_account_currency,
"gain_loss": gain_loss,
}
)
# Handle Accounts with '0' balance in Account/Base Currency
for d in [x for x in account_details if x.zero_balance]:
@@ -288,23 +303,22 @@ class ExchangeRateRevaluation(Document):
current_exchange_rate * d.balance_in_account_currency
)
if gain_loss:
accounts.append(
{
"account": d.account,
"party_type": d.party_type,
"party": d.party,
"account_currency": d.account_currency,
"balance_in_base_currency": d.balance,
"balance_in_account_currency": d.balance_in_account_currency,
"zero_balance": d.zero_balance,
"current_exchange_rate": current_exchange_rate,
"new_exchange_rate": new_exchange_rate,
"new_balance_in_base_currency": new_balance_in_base_currency,
"new_balance_in_account_currency": new_balance_in_account_currency,
"gain_loss": gain_loss,
}
)
accounts.append(
{
"account": d.account,
"party_type": d.party_type,
"party": d.party,
"account_currency": d.account_currency,
"balance_in_base_currency": d.balance,
"balance_in_account_currency": d.balance_in_account_currency,
"zero_balance": d.zero_balance,
"current_exchange_rate": current_exchange_rate,
"new_exchange_rate": new_exchange_rate,
"new_balance_in_base_currency": new_balance_in_base_currency,
"new_balance_in_account_currency": new_balance_in_account_currency,
"gain_loss": gain_loss,
}
)
return accounts

View File

@@ -188,7 +188,7 @@ class TestExchangeRateRevaluation(AccountsTestMixin, FrappeTestCase):
pe = get_payment_entry(si.doctype, si.name)
pe.paid_amount = 95
pe.source_exchange_rate = 84.211
pe.source_exchange_rate = 84.2105
pe.received_amount = 8000
pe.references = []
pe.save().submit()
@@ -229,7 +229,7 @@ class TestExchangeRateRevaluation(AccountsTestMixin, FrappeTestCase):
row = next(x for x in je.accounts if x.account == self.debtors_usd)
self.assertEqual(flt(row.credit_in_account_currency, precision), 5.0) # in USD
row = next(x for x in je.accounts if x.account != self.debtors_usd)
self.assertEqual(flt(row.debit_in_account_currency, precision), 421.06) # in INR
self.assertEqual(flt(row.debit_in_account_currency, precision), 421.05) # in INR
# total_debit and total_credit will be 0.0, as JV is posting only to account currency fields
self.assertEqual(flt(je.total_debit, precision), 0.0)

View File

@@ -4,10 +4,7 @@
frappe.ui.form.on("Fiscal Year", {
onload: function (frm) {
if (frm.doc.__islocal) {
frm.set_value(
"year_start_date",
frappe.datetime.add_days(frappe.defaults.get_default("year_end_date"), 1)
);
frm.set_value("year_start_date", frappe.datetime.year_start());
}
},
year_start_date: function (frm) {

View File

@@ -72,10 +72,10 @@
},
{
"default": "0",
"description": "Less than 12 months.",
"description": "More/Less than 12 months.",
"fieldname": "is_short_year",
"fieldtype": "Check",
"label": "Is Short Year",
"label": "Is Short/Long Year",
"set_only_once": 1
}
],

View File

@@ -6,38 +6,50 @@
"document_type": "Document",
"engine": "InnoDB",
"field_order": [
"dates_section",
"posting_date",
"transaction_date",
"column_break_avko",
"fiscal_year",
"due_date",
"account_details_section",
"account",
"account_currency",
"column_break_ifvf",
"against",
"party_type",
"party",
"cost_center",
"debit",
"credit",
"account_currency",
"debit_in_account_currency",
"credit_in_account_currency",
"against",
"transaction_details_section",
"voucher_type",
"voucher_no",
"voucher_subtype",
"transaction_currency",
"column_break_dpsx",
"against_voucher_type",
"against_voucher",
"voucher_type",
"voucher_subtype",
"voucher_no",
"voucher_detail_no",
"transaction_exchange_rate",
"amounts_section",
"debit_in_account_currency",
"debit",
"debit_in_transaction_currency",
"column_break_bm1w",
"credit_in_account_currency",
"credit",
"credit_in_transaction_currency",
"dimensions_section",
"cost_center",
"column_break_lmnm",
"project",
"remarks",
"more_info_section",
"finance_book",
"company",
"is_opening",
"is_advance",
"fiscal_year",
"company",
"finance_book",
"column_break_8abq",
"to_rename",
"due_date",
"is_cancelled",
"transaction_currency",
"debit_in_transaction_currency",
"credit_in_transaction_currency",
"transaction_exchange_rate"
"remarks"
],
"fields": [
{
@@ -285,13 +297,67 @@
"fieldname": "voucher_subtype",
"fieldtype": "Small Text",
"label": "Voucher Subtype"
},
{
"fieldname": "dates_section",
"fieldtype": "Section Break",
"label": "Dates"
},
{
"fieldname": "column_break_avko",
"fieldtype": "Column Break"
},
{
"fieldname": "account_details_section",
"fieldtype": "Section Break",
"label": "Account Details"
},
{
"fieldname": "column_break_ifvf",
"fieldtype": "Column Break"
},
{
"fieldname": "transaction_details_section",
"fieldtype": "Section Break",
"label": "Transaction Details"
},
{
"fieldname": "amounts_section",
"fieldtype": "Section Break",
"label": "Amounts"
},
{
"fieldname": "column_break_dpsx",
"fieldtype": "Column Break"
},
{
"fieldname": "more_info_section",
"fieldtype": "Section Break",
"label": "More Info"
},
{
"fieldname": "column_break_bm1w",
"fieldtype": "Column Break"
},
{
"fieldname": "dimensions_section",
"fieldtype": "Section Break",
"label": "Dimensions"
},
{
"fieldname": "column_break_lmnm",
"fieldtype": "Column Break"
},
{
"fieldname": "column_break_8abq",
"fieldtype": "Column Break"
}
],
"icon": "fa fa-list",
"idx": 1,
"in_create": 1,
"links": [],
"modified": "2024-07-02 14:31:51.496466",
"modified": "2024-08-22 13:03:39.997475",
"modified_by": "Administrator",
"module": "Accounts",
"name": "GL Entry",

View File

@@ -430,8 +430,9 @@ def update_against_account(voucher_type, voucher_no):
def on_doctype_update():
frappe.db.add_index("GL Entry", ["against_voucher_type", "against_voucher"])
frappe.db.add_index("GL Entry", ["voucher_type", "voucher_no"])
frappe.db.add_index("GL Entry", ["posting_date", "company"])
frappe.db.add_index("GL Entry", ["party_type", "party"])
def rename_gle_sle_docs():

View File

@@ -360,21 +360,23 @@ erpnext.accounts.JournalEntry = class JournalEntry extends frappe.ui.form.Contro
accounts_add(doc, cdt, cdn) {
var row = frappe.get_doc(cdt, cdn);
row.exchange_rate = 1;
$.each(doc.accounts, function (i, d) {
if (d.account && d.party && d.party_type) {
row.account = d.account;
row.party = d.party;
row.party_type = d.party_type;
row.exchange_rate = d.exchange_rate;
}
});
// set difference
if (doc.difference) {
if (doc.difference > 0) {
row.credit_in_account_currency = doc.difference;
row.credit_in_account_currency = doc.difference / row.exchange_rate;
row.credit = doc.difference;
} else {
row.debit_in_account_currency = -doc.difference;
row.debit_in_account_currency = -doc.difference / row.exchange_rate;
row.debit = -doc.difference;
}
}
@@ -680,6 +682,7 @@ $.extend(erpnext.journal_entry, {
callback: function (r) {
if (r.message) {
$.extend(d, r.message);
erpnext.journal_entry.set_amount_on_last_row(frm, dt, dn);
erpnext.journal_entry.set_debit_credit_in_company_currency(frm, dt, dn);
refresh_field("accounts");
}
@@ -687,4 +690,26 @@ $.extend(erpnext.journal_entry, {
});
}
},
set_amount_on_last_row: function (frm, dt, dn) {
let row = locals[dt][dn];
let length = frm.doc.accounts.length;
if (row.idx != length) return;
let difference = frm.doc.accounts.reduce((total, row) => {
if (row.idx == length) return total;
return total + row.debit - row.credit;
}, 0);
if (difference) {
if (difference > 0) {
row.credit_in_account_currency = difference / row.exchange_rate;
row.credit = difference;
} else {
row.debit_in_account_currency = -difference / row.exchange_rate;
row.debit = -difference;
}
}
refresh_field("accounts");
},
});

View File

@@ -127,9 +127,6 @@ class JournalEntry(AccountsController):
self.set_amounts_in_company_currency()
self.validate_debit_credit_amount()
self.set_total_debit_credit()
# Do not validate while importing via data import
if not frappe.flags.in_import:
self.validate_total_debit_and_credit()
if not frappe.flags.is_reverse_depr_entry:
self.validate_against_jv()
@@ -184,10 +181,16 @@ class JournalEntry(AccountsController):
else:
return self._cancel()
def before_submit(self):
# Do not validate while importing via data import
if not frappe.flags.in_import:
self.validate_total_debit_and_credit()
def on_submit(self):
self.validate_cheque_info()
self.check_credit_limit()
self.make_gl_entries()
self.make_advance_payment_ledger_entries()
self.update_advance_paid()
self.update_asset_value()
self.update_inter_company_jv()
@@ -195,6 +198,11 @@ class JournalEntry(AccountsController):
self.update_booked_depreciation()
def on_update_after_submit(self):
# Flag will be set on Reconciliation
# Reconciliation tool will anyways repost ledger entries. So, no need to check and do implicit repost.
if self.flags.get("ignore_reposting_on_reconciliation"):
return
self.needs_repost = self.check_if_fields_updated(fields_to_check=[], child_tables={"accounts": []})
if self.needs_repost:
self.validate_for_repost()
@@ -213,8 +221,10 @@ class JournalEntry(AccountsController):
"Repost Accounting Ledger Items",
"Unreconcile Payment",
"Unreconcile Payment Entries",
"Advance Payment Ledger Entry",
)
self.make_gl_entries(1)
self.make_advance_payment_ledger_entries()
self.update_advance_paid()
self.unlink_advance_entry_reference()
self.unlink_asset_reference()
@@ -254,7 +264,7 @@ class JournalEntry(AccountsController):
frappe.throw(_("Journal Entry type should be set as Depreciation Entry for asset depreciation"))
def validate_stock_accounts(self):
stock_accounts = get_stock_accounts(self.company, self.doctype, self.name)
stock_accounts = get_stock_accounts(self.company, accounts=self.accounts)
for account in stock_accounts:
account_bal, stock_bal, warehouse_list = get_stock_and_account_balance(
account, self.posting_date, self.company
@@ -1663,6 +1673,8 @@ def make_reverse_journal_entry(source_name, target_doc=None):
"debit": "credit",
"credit_in_account_currency": "debit_in_account_currency",
"credit": "debit",
"reference_type": "reference_type",
"reference_name": "reference_name",
},
},
},

View File

@@ -515,6 +515,72 @@ class TestJournalEntry(unittest.TestCase):
self.assertEqual(row.debit_in_account_currency, 100)
self.assertEqual(row.credit_in_account_currency, 100)
def test_toggle_debit_credit_if_negative(self):
from erpnext.accounts.general_ledger import process_gl_map
# Create JV with defaut cost center - _Test Cost Center
frappe.db.set_single_value("Accounts Settings", "merge_similar_account_heads", 0)
jv = frappe.new_doc("Journal Entry")
jv.posting_date = nowdate()
jv.company = "_Test Company"
jv.user_remark = "test"
jv.extend(
"accounts",
[
{
"account": "_Test Cash - _TC",
"debit": 100 * -1,
"debit_in_account_currency": 100 * -1,
"exchange_rate": 1,
},
{
"account": "_Test Bank - _TC",
"credit": 100 * -1,
"credit_in_account_currency": 100 * -1,
"exchange_rate": 1,
},
],
)
jv.flags.ignore_validate = True
jv.save()
self.assertEqual(len(jv.accounts), 2)
gl_map = jv.build_gl_map()
for row in gl_map:
if row.account == "_Test Cash - _TC":
self.assertEqual(row.debit, 100 * -1)
self.assertEqual(row.debit_in_account_currency, 100 * -1)
self.assertEqual(row.debit_in_transaction_currency, 100 * -1)
gl_map = process_gl_map(gl_map, False)
for row in gl_map:
if row.account == "_Test Cash - _TC":
self.assertEqual(row.credit, 100)
self.assertEqual(row.credit_in_account_currency, 100)
self.assertEqual(row.credit_in_transaction_currency, 100)
def test_transaction_exchange_rate_on_journals(self):
jv = make_journal_entry("_Test Bank - _TC", "_Test Receivable USD - _TC", 100, save=False)
jv.accounts[0].update({"debit_in_account_currency": 8500, "exchange_rate": 1})
jv.accounts[1].update({"party_type": "Customer", "party": "_Test Customer USD", "exchange_rate": 85})
jv.submit()
actual = frappe.db.get_all(
"GL Entry",
filters={"voucher_no": jv.name, "is_cancelled": 0},
fields=["account", "transaction_exchange_rate"],
order_by="account",
)
expected = [
{"account": "_Test Bank - _TC", "transaction_exchange_rate": 1.0},
{"account": "_Test Receivable USD - _TC", "transaction_exchange_rate": 85.0},
]
self.assertEqual(expected, actual)
def make_journal_entry(
account1,

View File

@@ -28,7 +28,12 @@ frappe.ui.form.on("Opening Invoice Creation Tool", {
frm.refresh_fields();
frm.page.clear_indicator();
frm.dashboard.hide_progress();
frappe.msgprint(__("Opening {0} Invoices created", [frm.doc.invoice_type]));
if (frm.doc.invoice_type == "Sales") {
frappe.msgprint(__("Opening Sales Invoices have been created."));
} else {
frappe.msgprint(__("Opening Purchase Invoices have been created."));
}
},
1500,
data.title
@@ -48,12 +53,19 @@ frappe.ui.form.on("Opening Invoice Creation Tool", {
!frm.doc.import_in_progress && frm.trigger("make_dashboard");
frm.page.set_primary_action(__("Create Invoices"), () => {
let btn_primary = frm.page.btn_primary.get(0);
let freeze_message;
if (frm.doc.invoice_type == "Sales") {
freeze_message = __("Creating Sales Invoices ...");
} else {
freeze_message = __("Creating Purchase Invoices ...");
}
return frm.call({
doc: frm.doc,
btn: $(btn_primary),
method: "make_invoices",
freeze: 1,
freeze_message: __("Creating {0} Invoice", [frm.doc.invoice_type]),
freeze_message: freeze_message,
});
});

View File

@@ -26,6 +26,10 @@ frappe.ui.form.on("Payment Entry", {
}
erpnext.accounts.dimensions.setup_dimension_filters(frm, frm.doctype);
if (frm.is_new()) {
set_default_party_type(frm);
}
},
setup: function (frm) {
@@ -35,6 +39,11 @@ frappe.ui.form.on("Payment Entry", {
var account_types = ["Pay", "Internal Transfer"].includes(frm.doc.payment_type)
? ["Bank", "Cash"]
: [frappe.boot.party_account_types[frm.doc.party_type]];
if (frm.doc.party_type == "Shareholder") {
account_types.push("Equity");
}
return {
filters: {
account_type: ["in", account_types],
@@ -90,6 +99,9 @@ frappe.ui.form.on("Payment Entry", {
var account_types = ["Receive", "Internal Transfer"].includes(frm.doc.payment_type)
? ["Bank", "Cash"]
: [frappe.boot.party_account_types[frm.doc.party_type]];
if (frm.doc.party_type == "Shareholder") {
account_types.push("Equity");
}
return {
filters: {
account_type: ["in", account_types],
@@ -166,6 +178,21 @@ frappe.ui.form.on("Payment Entry", {
};
});
frm.set_query("payment_request", "references", function (doc, cdt, cdn) {
const row = frappe.get_doc(cdt, cdn);
return {
query: "erpnext.accounts.doctype.payment_request.payment_request.get_open_payment_requests_query",
filters: {
reference_doctype: row.reference_doctype,
reference_name: row.reference_name,
company: doc.company,
status: ["!=", "Paid"],
outstanding_amount: [">", 0], // for compatibility with old data
docstatus: 1,
},
};
});
frm.set_query("sales_taxes_and_charges_template", function () {
return {
filters: {
@@ -183,7 +210,15 @@ frappe.ui.form.on("Payment Entry", {
},
};
});
frm.add_fetch(
"payment_request",
"outstanding_amount",
"payment_request_outstanding",
"Payment Entry Reference"
);
},
refresh: function (frm) {
erpnext.hide_company(frm);
frm.events.hide_unhide_fields(frm);
@@ -208,6 +243,7 @@ frappe.ui.form.on("Payment Entry", {
);
}
erpnext.accounts.unreconcile_payment.add_unreconcile_btn(frm);
frappe.flags.allocate_payment_amount = true;
},
validate_company: (frm) => {
@@ -288,16 +324,11 @@ frappe.ui.form.on("Payment Entry", {
"write_off_difference_amount",
frm.doc.difference_amount && frm.doc.party && frm.doc.total_allocated_amount > party_amount
);
frm.toggle_display(
"set_exchange_gain_loss",
frm.doc.paid_amount && frm.doc.received_amount && frm.doc.difference_amount
);
},
set_dynamic_labels: function (frm) {
var company_currency = frm.doc.company
? frappe.get_doc(":Company", frm.doc.company).default_currency
? frappe.get_doc(":Company", frm.doc.company)?.default_currency
: "";
frm.set_currency_labels(
@@ -375,9 +406,19 @@ frappe.ui.form.on("Payment Entry", {
},
payment_type: function (frm) {
set_default_party_type(frm);
if (frm.doc.payment_type == "Internal Transfer") {
$.each(
["party", "party_balance", "paid_from", "paid_to", "references", "total_allocated_amount"],
[
"party",
"party_type",
"party_balance",
"paid_from",
"paid_to",
"references",
"total_allocated_amount",
],
function (i, field) {
frm.set_value(field, null);
}
@@ -412,6 +453,12 @@ frappe.ui.form.on("Payment Entry", {
return {
query: "erpnext.controllers.queries.employee_query",
};
} else if (frm.doc.party_type == "Shareholder") {
return {
filters: {
company: frm.doc.company,
},
};
}
});
@@ -644,7 +691,7 @@ frappe.ui.form.on("Payment Entry", {
frm.set_value("source_exchange_rate", 1);
} else if (frm.doc.paid_from) {
if (["Internal Transfer", "Pay"].includes(frm.doc.payment_type)) {
let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
let company_currency = frappe.get_doc(":Company", frm.doc.company)?.default_currency;
frappe.call({
method: "erpnext.setup.utils.get_exchange_rate",
args: {
@@ -775,7 +822,7 @@ frappe.ui.form.on("Payment Entry", {
);
if (frm.doc.payment_type == "Pay")
frm.events.allocate_party_amount_against_ref_docs(frm, frm.doc.received_amount, 1);
frm.events.allocate_party_amount_against_ref_docs(frm, frm.doc.received_amount, true);
else frm.events.set_unallocated_amount(frm);
frm.set_paid_amount_based_on_received_amount = false;
@@ -796,7 +843,7 @@ frappe.ui.form.on("Payment Entry", {
}
if (frm.doc.payment_type == "Receive")
frm.events.allocate_party_amount_against_ref_docs(frm, frm.doc.paid_amount, 1);
frm.events.allocate_party_amount_against_ref_docs(frm, frm.doc.paid_amount, true);
else frm.events.set_unallocated_amount(frm);
},
@@ -967,6 +1014,7 @@ frappe.ui.form.on("Payment Entry", {
c.outstanding_amount = d.outstanding_amount;
c.bill_no = d.bill_no;
c.payment_term = d.payment_term;
c.payment_term_outstanding = d.payment_term_outstanding;
c.allocated_amount = d.allocated_amount;
c.account = d.account;
@@ -1016,7 +1064,8 @@ frappe.ui.form.on("Payment Entry", {
frm.events.allocate_party_amount_against_ref_docs(
frm,
frm.doc.payment_type == "Receive" ? frm.doc.paid_amount : frm.doc.received_amount
frm.doc.payment_type == "Receive" ? frm.doc.paid_amount : frm.doc.received_amount,
false
);
},
});
@@ -1030,93 +1079,13 @@ frappe.ui.form.on("Payment Entry", {
return ["Sales Invoice", "Purchase Invoice"];
},
allocate_party_amount_against_ref_docs: function (frm, paid_amount, paid_amount_change) {
var total_positive_outstanding_including_order = 0;
var total_negative_outstanding = 0;
var total_deductions = frappe.utils.sum(
$.map(frm.doc.deductions || [], function (d) {
return flt(d.amount);
})
);
paid_amount -= total_deductions;
$.each(frm.doc.references || [], function (i, row) {
if (flt(row.outstanding_amount) > 0)
total_positive_outstanding_including_order += flt(row.outstanding_amount);
else total_negative_outstanding += Math.abs(flt(row.outstanding_amount));
allocate_party_amount_against_ref_docs: async function (frm, paid_amount, paid_amount_change) {
await frm.call("allocate_amount_to_references", {
paid_amount: paid_amount,
paid_amount_change: paid_amount_change,
allocate_payment_amount: frappe.flags.allocate_payment_amount ?? false,
});
var allocated_negative_outstanding = 0;
if (
(frm.doc.payment_type == "Receive" && frm.doc.party_type == "Customer") ||
(frm.doc.payment_type == "Pay" && frm.doc.party_type == "Supplier") ||
(frm.doc.payment_type == "Pay" && frm.doc.party_type == "Employee")
) {
if (total_positive_outstanding_including_order > paid_amount) {
var remaining_outstanding = total_positive_outstanding_including_order - paid_amount;
allocated_negative_outstanding =
total_negative_outstanding < remaining_outstanding
? total_negative_outstanding
: remaining_outstanding;
}
var allocated_positive_outstanding = paid_amount + allocated_negative_outstanding;
} else if (["Customer", "Supplier"].includes(frm.doc.party_type)) {
total_negative_outstanding = flt(total_negative_outstanding, precision("outstanding_amount"));
if (paid_amount > total_negative_outstanding) {
if (total_negative_outstanding == 0) {
frappe.msgprint(
__("Cannot {0} {1} {2} without any negative outstanding invoice", [
frm.doc.payment_type,
frm.doc.party_type == "Customer" ? "to" : "from",
frm.doc.party_type,
])
);
return false;
} else {
frappe.msgprint(
__("Paid Amount cannot be greater than total negative outstanding amount {0}", [
total_negative_outstanding,
])
);
return false;
}
} else {
allocated_positive_outstanding = total_negative_outstanding - paid_amount;
allocated_negative_outstanding =
paid_amount +
(total_positive_outstanding_including_order < allocated_positive_outstanding
? total_positive_outstanding_including_order
: allocated_positive_outstanding);
}
}
$.each(frm.doc.references || [], function (i, row) {
if (frappe.flags.allocate_payment_amount == 0) {
//If allocate payment amount checkbox is unchecked, set zero to allocate amount
row.allocated_amount = 0;
} else if (
frappe.flags.allocate_payment_amount != 0 &&
(!row.allocated_amount || paid_amount_change)
) {
if (row.outstanding_amount > 0 && allocated_positive_outstanding >= 0) {
row.allocated_amount =
row.outstanding_amount >= allocated_positive_outstanding
? allocated_positive_outstanding
: row.outstanding_amount;
allocated_positive_outstanding -= flt(row.allocated_amount);
} else if (row.outstanding_amount < 0 && allocated_negative_outstanding) {
row.allocated_amount =
Math.abs(row.outstanding_amount) >= allocated_negative_outstanding
? -1 * allocated_negative_outstanding
: row.outstanding_amount;
allocated_negative_outstanding -= Math.abs(flt(row.allocated_amount));
}
}
});
frm.refresh_fields();
frm.events.set_total_allocated_amount(frm);
},
@@ -1145,36 +1114,34 @@ frappe.ui.form.on("Payment Entry", {
},
set_unallocated_amount: function (frm) {
var unallocated_amount = 0;
var total_deductions = frappe.utils.sum(
$.map(frm.doc.deductions || [], function (d) {
return flt(d.amount);
})
);
let unallocated_amount = 0;
let deductions_to_consider = 0;
for (const row of frm.doc.deductions || []) {
if (!row.is_exchange_gain_loss) deductions_to_consider += flt(row.amount);
}
const included_taxes = get_included_taxes(frm);
if (frm.doc.party) {
if (
frm.doc.payment_type == "Receive" &&
frm.doc.base_total_allocated_amount < frm.doc.base_received_amount + total_deductions &&
frm.doc.total_allocated_amount <
frm.doc.paid_amount + total_deductions / frm.doc.source_exchange_rate
) {
unallocated_amount =
(frm.doc.base_received_amount +
total_deductions -
flt(frm.doc.base_total_taxes_and_charges) -
frm.doc.base_total_allocated_amount) /
frm.doc.source_exchange_rate;
} else if (
frm.doc.payment_type == "Pay" &&
frm.doc.base_total_allocated_amount < frm.doc.base_paid_amount - total_deductions &&
frm.doc.total_allocated_amount <
frm.doc.received_amount + total_deductions / frm.doc.target_exchange_rate
frm.doc.base_total_allocated_amount < frm.doc.base_paid_amount + deductions_to_consider
) {
unallocated_amount =
(frm.doc.base_paid_amount +
flt(frm.doc.base_total_taxes_and_charges) -
(total_deductions + frm.doc.base_total_allocated_amount)) /
deductions_to_consider -
frm.doc.base_total_allocated_amount -
included_taxes) /
frm.doc.source_exchange_rate;
} else if (
frm.doc.payment_type == "Pay" &&
frm.doc.base_total_allocated_amount < frm.doc.base_received_amount - deductions_to_consider
) {
unallocated_amount =
(frm.doc.base_received_amount -
deductions_to_consider -
frm.doc.base_total_allocated_amount -
included_taxes) /
frm.doc.target_exchange_rate;
}
}
@@ -1268,77 +1235,85 @@ frappe.ui.form.on("Payment Entry", {
},
write_off_difference_amount: function (frm) {
frm.events.set_deductions_entry(frm, "write_off_account");
frm.events.set_write_off_deduction(frm);
},
set_exchange_gain_loss: function (frm) {
frm.events.set_deductions_entry(frm, "exchange_gain_loss_account");
base_paid_amount: function (frm) {
frm.events.set_exchange_gain_loss_deduction(frm);
},
set_deductions_entry: function (frm, account) {
if (frm.doc.difference_amount) {
frappe.call({
method: "erpnext.accounts.doctype.payment_entry.payment_entry.get_company_defaults",
args: {
company: frm.doc.company,
},
callback: function (r, rt) {
if (r.message) {
const write_off_row = $.map(frm.doc["deductions"] || [], function (t) {
return t.account == r.message[account] ? t : null;
});
base_received_amount: function (frm) {
frm.events.set_exchange_gain_loss_deduction(frm);
},
const difference_amount = flt(
frm.doc.difference_amount,
precision("difference_amount")
);
set_exchange_gain_loss_deduction: async function (frm) {
// wait for allocate_party_amount_against_ref_docs to finish
await frappe.after_ajax();
const base_paid_amount = frm.doc.base_paid_amount || 0;
const base_received_amount = frm.doc.base_received_amount || 0;
const exchange_gain_loss = flt(
base_paid_amount - base_received_amount,
get_deduction_amount_precision()
);
const add_deductions = (details) => {
let row = null;
if (!write_off_row.length && difference_amount) {
row = frm.add_child("deductions");
row.account = details[account];
row.cost_center = details["cost_center"];
} else {
row = write_off_row[0];
}
if (row) {
row.amount = flt(row.amount) + difference_amount;
} else {
frappe.msgprint(__("No gain or loss in the exchange rate"));
}
refresh_field("deductions");
};
if (!r.message[account]) {
frappe.prompt(
{
label: __("Please Specify Account"),
fieldname: account,
fieldtype: "Link",
options: "Account",
get_query: () => ({
filters: {
company: frm.doc.company,
},
}),
},
(values) => {
const details = Object.assign({}, r.message, values);
add_deductions(details);
},
__(frappe.unscrub(account))
);
} else {
add_deductions(r.message);
}
frm.events.set_unallocated_amount(frm);
}
},
});
if (!exchange_gain_loss) {
frm.events.delete_exchange_gain_loss(frm);
return;
}
const account_fieldname = "exchange_gain_loss_account";
let row = (frm.doc.deductions || []).find((t) => t.is_exchange_gain_loss);
if (!row) {
const response = await get_company_defaults(frm.doc.company);
const account =
response.message?.[account_fieldname] ||
(await prompt_for_missing_account(frm, account_fieldname));
row = frm.add_child("deductions");
row.account = account;
row.cost_center = response.message?.cost_center;
row.is_exchange_gain_loss = 1;
}
row.amount = exchange_gain_loss;
frm.refresh_field("deductions");
frm.events.set_unallocated_amount(frm);
},
delete_exchange_gain_loss: function (frm) {
const exchange_gain_loss_row = (frm.doc.deductions || []).find((row) => row.is_exchange_gain_loss);
if (!exchange_gain_loss_row) return;
exchange_gain_loss_row.amount = 0;
frm.get_field("deductions").grid.grid_rows[exchange_gain_loss_row.idx - 1].remove();
frm.refresh_field("deductions");
},
set_write_off_deduction: async function (frm) {
const difference_amount = flt(frm.doc.difference_amount, get_deduction_amount_precision());
if (!difference_amount) return;
const account_fieldname = "write_off_account";
const response = await get_company_defaults(frm.doc.company);
const write_off_account =
response.message?.[account_fieldname] ||
(await prompt_for_missing_account(frm, account_fieldname));
if (!write_off_account) return;
let row = (frm.doc["deductions"] || []).find((t) => t.account == write_off_account);
if (!row) {
row = frm.add_child("deductions");
row.account = write_off_account;
row.cost_center = response.message?.cost_center;
}
row.amount = flt(row.amount) + difference_amount;
frm.refresh_field("deductions");
frm.events.set_unallocated_amount(frm);
},
bank_account: function (frm) {
@@ -1664,6 +1639,62 @@ frappe.ui.form.on("Payment Entry", {
return current_tax_amount;
},
cost_center: function (frm) {
if (frm.doc.posting_date && (frm.doc.paid_from || frm.doc.paid_to)) {
return frappe.call({
method: "erpnext.accounts.doctype.payment_entry.payment_entry.get_party_and_account_balance",
args: {
company: frm.doc.company,
date: frm.doc.posting_date,
paid_from: frm.doc.paid_from,
paid_to: frm.doc.paid_to,
ptype: frm.doc.party_type,
pty: frm.doc.party,
cost_center: frm.doc.cost_center,
},
callback: function (r, rt) {
if (r.message) {
frappe.run_serially([
() => {
frm.set_value(
"paid_from_account_balance",
r.message.paid_from_account_balance
);
frm.set_value("paid_to_account_balance", r.message.paid_to_account_balance);
frm.set_value("party_balance", r.message.party_balance);
},
]);
}
},
});
}
},
after_save: function (frm) {
const { matched_payment_requests } = frappe.last_response;
if (!matched_payment_requests) return;
const COLUMN_LABEL = [
[__("Reference DocType"), __("Reference Name"), __("Allocated Amount"), __("Payment Request")],
];
frappe.msgprint({
title: __("Unset Matched Payment Request"),
message: COLUMN_LABEL.concat(matched_payment_requests),
as_table: true,
wide: true,
primary_action: {
label: __("Allocate Payment Request"),
action() {
frappe.hide_msgprint();
frm.call("set_matched_payment_requests", { matched_payment_requests }, () => {
frm.dirty();
});
},
},
});
},
});
frappe.ui.form.on("Payment Entry Reference", {
@@ -1748,6 +1779,13 @@ frappe.ui.form.on("Advance Taxes and Charges", {
});
frappe.ui.form.on("Payment Entry Deduction", {
before_deductions_remove: function (doc, cdt, cdn) {
const row = frappe.get_doc(cdt, cdn);
if (row.is_exchange_gain_loss && row.amount) {
frappe.throw(__("Cannot delete Exchange Gain/Loss row"));
}
},
amount: function (frm) {
frm.events.set_unallocated_amount(frm);
},
@@ -1756,35 +1794,66 @@ frappe.ui.form.on("Payment Entry Deduction", {
frm.events.set_unallocated_amount(frm);
},
});
frappe.ui.form.on("Payment Entry", {
cost_center: function (frm) {
if (frm.doc.posting_date && (frm.doc.paid_from || frm.doc.paid_to)) {
return frappe.call({
method: "erpnext.accounts.doctype.payment_entry.payment_entry.get_party_and_account_balance",
args: {
company: frm.doc.company,
date: frm.doc.posting_date,
paid_from: frm.doc.paid_from,
paid_to: frm.doc.paid_to,
ptype: frm.doc.party_type,
pty: frm.doc.party,
cost_center: frm.doc.cost_center,
},
callback: function (r, rt) {
if (r.message) {
frappe.run_serially([
() => {
frm.set_value(
"paid_from_account_balance",
r.message.paid_from_account_balance
);
frm.set_value("paid_to_account_balance", r.message.paid_to_account_balance);
frm.set_value("party_balance", r.message.party_balance);
},
]);
}
},
});
function set_default_party_type(frm) {
if (frm.doc.party) return;
let party_type;
if (frm.doc.payment_type == "Receive") {
party_type = "Customer";
} else if (frm.doc.payment_type == "Pay") {
party_type = "Supplier";
}
if (party_type) frm.set_value("party_type", party_type);
}
function get_included_taxes(frm) {
let included_taxes = 0;
for (const tax of frm.doc.taxes) {
if (!tax.included_in_paid_amount) continue;
if (tax.add_deduct_tax == "Add") {
included_taxes += tax.base_tax_amount;
} else {
included_taxes -= tax.base_tax_amount;
}
},
});
}
return included_taxes;
}
function get_company_defaults(company) {
return frappe.call({
method: "erpnext.accounts.doctype.payment_entry.payment_entry.get_company_defaults",
args: {
company: company,
},
});
}
function prompt_for_missing_account(frm, account) {
return new Promise((resolve) => {
const dialog = frappe.prompt(
{
label: __(frappe.unscrub(account)),
fieldname: account,
fieldtype: "Link",
options: "Account",
get_query: () => ({
filters: {
company: frm.doc.company,
},
}),
},
(values) => resolve(values?.[account]),
__("Please Specify Account")
);
dialog.on_hide = () => resolve("");
});
}
function get_deduction_amount_precision() {
return frappe.meta.get_field_precision(frappe.meta.get_field("Payment Entry Deduction", "amount"));
}

View File

@@ -56,7 +56,6 @@
"section_break_34",
"total_allocated_amount",
"base_total_allocated_amount",
"set_exchange_gain_loss",
"column_break_36",
"unallocated_amount",
"difference_amount",
@@ -390,11 +389,6 @@
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "set_exchange_gain_loss",
"fieldtype": "Button",
"label": "Set Exchange Gain / Loss"
},
{
"fieldname": "column_break_36",
"fieldtype": "Column Break"
@@ -801,7 +795,7 @@
"table_fieldname": "payment_entries"
}
],
"modified": "2024-05-31 17:07:06.197249",
"modified": "2024-11-07 11:19:19.320883",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Entry",

View File

@@ -7,8 +7,10 @@ from functools import reduce
import frappe
from frappe import ValidationError, _, qb, scrub, throw
from frappe.query_builder import Tuple
from frappe.query_builder.functions import Count
from frappe.utils import cint, comma_or, flt, getdate, nowdate
from frappe.utils.data import comma_and, fmt_money
from frappe.utils.data import comma_and, fmt_money, get_link_to_form
from pypika import Case
from pypika.functions import Coalesce, Sum
@@ -98,13 +100,18 @@ class PaymentEntry(AccountsController):
self.set_status()
self.set_total_in_words()
def before_save(self):
self.set_matched_unset_payment_requests_to_response()
def on_submit(self):
if self.difference_amount:
frappe.throw(_("Difference Amount must be zero"))
self.make_gl_entries()
self.update_outstanding_amounts()
self.update_advance_paid()
self.update_payment_schedule()
self.update_payment_requests()
self.make_advance_payment_ledger_entries()
self.update_advance_paid() # advance_paid_status depends on the payment request amount
self.set_status()
def set_liability_account(self):
@@ -145,9 +152,21 @@ class PaymentEntry(AccountsController):
self.is_opening = "No"
return
liability_account = get_party_account(
self.party_type, self.party, self.company, include_advance=True
)[1]
accounts = get_party_account(self.party_type, self.party, self.company, include_advance=True)
liability_account = accounts[1] if len(accounts) > 1 else None
fieldname = (
"default_advance_received_account"
if self.party_type == "Customer"
else "default_advance_paid_account"
)
if not liability_account:
throw(
_("Please set default {0} in Company {1}").format(
frappe.bold(frappe.get_meta("Company").get_label(fieldname)), frappe.bold(self.company)
)
)
self.set(self.party_account_field, liability_account)
@@ -172,34 +191,40 @@ class PaymentEntry(AccountsController):
"Repost Accounting Ledger Items",
"Unreconcile Payment",
"Unreconcile Payment Entries",
"Advance Payment Ledger Entry",
)
super().on_cancel()
self.make_gl_entries(cancel=1)
self.update_outstanding_amounts()
self.update_advance_paid()
self.delink_advance_entry_references()
self.update_payment_schedule(cancel=1)
self.set_payment_req_status()
self.update_payment_requests(cancel=True)
self.make_advance_payment_ledger_entries()
self.update_advance_paid() # advance_paid_status depends on the payment request amount
self.set_status()
def set_payment_req_status(self):
from erpnext.accounts.doctype.payment_request.payment_request import update_payment_req_status
def update_payment_requests(self, cancel=False):
from erpnext.accounts.doctype.payment_request.payment_request import (
update_payment_requests_as_per_pe_references,
)
update_payment_req_status(self, None)
update_payment_requests_as_per_pe_references(self.references, cancel=cancel)
def update_outstanding_amounts(self):
self.set_missing_ref_details(force=True)
def validate_duplicate_entry(self):
reference_names = []
reference_names = set()
for d in self.get("references"):
if (d.reference_doctype, d.reference_name, d.payment_term) in reference_names:
key = (d.reference_doctype, d.reference_name, d.payment_term, d.payment_request)
if key in reference_names:
frappe.throw(
_("Row #{0}: Duplicate entry in References {1} {2}").format(
d.idx, d.reference_doctype, d.reference_name
)
)
reference_names.append((d.reference_doctype, d.reference_name, d.payment_term))
reference_names.add(key)
def set_bank_account_data(self):
if self.bank_account:
@@ -225,6 +250,8 @@ class PaymentEntry(AccountsController):
if self.payment_type == "Internal Transfer":
return
self.validate_allocated_amount_as_per_payment_request()
if self.party_type in ("Customer", "Supplier"):
self.validate_allocated_amount_with_latest_data()
else:
@@ -237,6 +264,27 @@ class PaymentEntry(AccountsController):
if flt(d.allocated_amount) < 0 and flt(d.allocated_amount) < flt(d.outstanding_amount):
frappe.throw(fail_message.format(d.idx))
def validate_allocated_amount_as_per_payment_request(self):
"""
Allocated amount should not be greater than the outstanding amount of the Payment Request.
"""
if not self.references:
return
pr_outstanding_amounts = get_payment_request_outstanding_set_in_references(self.references)
if not pr_outstanding_amounts:
return
for ref in self.references:
if ref.payment_request and ref.allocated_amount > pr_outstanding_amounts[ref.payment_request]:
frappe.throw(
msg=_(
"Row #{0}: Allocated Amount cannot be greater than Outstanding Amount of Payment Request {1}"
).format(ref.idx, get_link_to_form("Payment Request", ref.payment_request)),
title=_("Invalid Allocated Amount"),
)
def term_based_allocation_enabled_for_reference(
self, reference_doctype: str, reference_name: str
) -> bool:
@@ -845,6 +893,7 @@ class PaymentEntry(AccountsController):
self.set_amounts_in_company_currency()
self.set_total_allocated_amount()
self.set_unallocated_amount()
self.set_exchange_gain_loss()
self.set_difference_amount()
def validate_amounts(self):
@@ -940,10 +989,10 @@ class PaymentEntry(AccountsController):
if d.exchange_rate is None:
d.exchange_rate = 1
allocated_amount_in_pe_exchange_rate = flt(
allocated_amount_in_ref_exchange_rate = flt(
flt(d.allocated_amount) * flt(d.exchange_rate), self.precision("base_paid_amount")
)
d.exchange_gain_loss = base_allocated_amount - allocated_amount_in_pe_exchange_rate
d.exchange_gain_loss = base_allocated_amount - allocated_amount_in_ref_exchange_rate
return base_allocated_amount
def set_total_allocated_amount(self):
@@ -961,29 +1010,80 @@ class PaymentEntry(AccountsController):
def set_unallocated_amount(self):
self.unallocated_amount = 0
if self.party:
total_deductions = sum(flt(d.amount) for d in self.get("deductions"))
included_taxes = self.get_included_taxes()
if (
self.payment_type == "Receive"
and self.base_total_allocated_amount < self.base_received_amount + total_deductions
and self.total_allocated_amount
< flt(self.paid_amount) + (total_deductions / self.source_exchange_rate)
):
self.unallocated_amount = (
self.base_received_amount + total_deductions - self.base_total_allocated_amount
) / self.source_exchange_rate
self.unallocated_amount -= included_taxes
elif (
self.payment_type == "Pay"
and self.base_total_allocated_amount < (self.base_paid_amount - total_deductions)
and self.total_allocated_amount
< flt(self.received_amount) + (total_deductions / self.target_exchange_rate)
):
self.unallocated_amount = (
self.base_paid_amount - (total_deductions + self.base_total_allocated_amount)
) / self.target_exchange_rate
self.unallocated_amount -= included_taxes
if not self.party:
return
deductions_to_consider = sum(
flt(d.amount) for d in self.get("deductions") if not d.is_exchange_gain_loss
)
included_taxes = self.get_included_taxes()
if self.payment_type == "Receive" and self.base_total_allocated_amount < (
self.base_paid_amount + deductions_to_consider
):
self.unallocated_amount = (
self.base_paid_amount
+ deductions_to_consider
- self.base_total_allocated_amount
- included_taxes
) / self.source_exchange_rate
elif self.payment_type == "Pay" and self.base_total_allocated_amount < (
self.base_received_amount - deductions_to_consider
):
self.unallocated_amount = (
self.base_received_amount
- deductions_to_consider
- self.base_total_allocated_amount
- included_taxes
) / self.target_exchange_rate
def set_exchange_gain_loss(self):
exchange_gain_loss = flt(
self.base_paid_amount - self.base_received_amount,
self.precision("amount", "deductions"),
)
exchange_gain_loss_rows = [row for row in self.get("deductions") if row.is_exchange_gain_loss]
exchange_gain_loss_row = exchange_gain_loss_rows.pop(0) if exchange_gain_loss_rows else None
for row in exchange_gain_loss_rows:
self.remove(row)
if not exchange_gain_loss:
if exchange_gain_loss_row:
self.remove(exchange_gain_loss_row)
return
if not exchange_gain_loss_row:
values = frappe.get_cached_value(
"Company", self.company, ("exchange_gain_loss_account", "cost_center"), as_dict=True
)
for fieldname, value in values.items():
if value:
continue
label = _(frappe.get_meta("Company").get_label(fieldname))
return frappe.msgprint(
_("Please set {0} in Company {1} to account for Exchange Gain / Loss").format(
label, get_link_to_form("Company", self.company)
),
title=_("Missing Default in Company"),
indicator="red" if self.docstatus.is_submitted() else "yellow",
raise_exception=self.docstatus.is_submitted(),
)
exchange_gain_loss_row = self.append(
"deductions",
{
"account": values.exchange_gain_loss_account,
"cost_center": values.cost_center,
"is_exchange_gain_loss": 1,
},
)
exchange_gain_loss_row.amount = exchange_gain_loss
def set_difference_amount(self):
base_unallocated_amount = flt(self.unallocated_amount) * (
@@ -1011,11 +1111,13 @@ class PaymentEntry(AccountsController):
def get_included_taxes(self):
included_taxes = 0
for tax in self.get("taxes"):
if tax.included_in_paid_amount:
if tax.add_deduct_tax == "Add":
included_taxes += tax.base_tax_amount
else:
included_taxes -= tax.base_tax_amount
if not tax.included_in_paid_amount:
continue
if tax.add_deduct_tax == "Add":
included_taxes += tax.base_tax_amount
else:
included_taxes -= tax.base_tax_amount
return included_taxes
@@ -1098,6 +1200,12 @@ class PaymentEntry(AccountsController):
if self.payment_type in ("Receive", "Pay") and not self.get("party_account_field"):
self.setup_party_account_field()
company_currency = erpnext.get_company_currency(self.company)
if self.paid_from_account_currency != company_currency:
self.currency = self.paid_from_account_currency
elif self.paid_to_account_currency != company_currency:
self.currency = self.paid_to_account_currency
gl_entries = []
self.add_party_gl_entries(gl_entries)
self.add_bank_gl_entries(gl_entries)
@@ -1121,6 +1229,8 @@ class PaymentEntry(AccountsController):
if not self.party_account:
return
advance_payment_doctypes = frappe.get_hooks("advance_payment_doctypes")
if self.payment_type == "Receive":
against_account = self.paid_to
else:
@@ -1163,14 +1273,41 @@ class PaymentEntry(AccountsController):
dr_or_cr = "debit" if dr_or_cr == "credit" else "credit"
gle.update(
{
dr_or_cr: allocated_amount_in_company_currency,
dr_or_cr + "_in_account_currency": d.allocated_amount,
"against_voucher_type": d.reference_doctype,
"against_voucher": d.reference_name,
"cost_center": cost_center,
}
self.get_gl_dict(
{
"account": self.party_account,
"party_type": self.party_type,
"party": self.party,
"against": against_account,
"account_currency": self.party_account_currency,
"cost_center": cost_center,
dr_or_cr + "_in_account_currency": d.allocated_amount,
dr_or_cr: allocated_amount_in_company_currency,
},
item=self,
)
)
if self.book_advance_payments_in_separate_party_account:
if d.reference_doctype in advance_payment_doctypes:
# Upon reconciliation, whole ledger will be reposted. So, reference to SO/PO is fine
gle.update(
{
"against_voucher_type": d.reference_doctype,
"against_voucher": d.reference_name,
}
)
else:
# Do not reference Invoices while Advance is in separate party account
gle.update({"against_voucher_type": self.doctype, "against_voucher": self.name})
else:
gle.update(
{
"against_voucher_type": d.reference_doctype,
"against_voucher": d.reference_name,
}
)
gl_entries.append(gle)
if self.unallocated_amount:
@@ -1179,13 +1316,22 @@ class PaymentEntry(AccountsController):
base_unallocated_amount = self.unallocated_amount * exchange_rate
gle = party_gl_dict.copy()
gle.update(
{
dr_or_cr + "_in_account_currency": self.unallocated_amount,
dr_or_cr: base_unallocated_amount,
}
)
gle.update(
self.get_gl_dict(
{
"account": self.party_account,
"party_type": self.party_type,
"party": self.party,
"against": against_account,
"account_currency": self.party_account_currency,
"cost_center": self.cost_center,
dr_or_cr + "_in_account_currency": self.unallocated_amount,
dr_or_cr: base_unallocated_amount,
},
item=self,
)
)
if self.book_advance_payments_in_separate_party_account:
gle.update(
{
@@ -1594,6 +1740,380 @@ class PaymentEntry(AccountsController):
return current_tax_fraction
def set_matched_unset_payment_requests_to_response(self):
"""
Find matched Payment Requests for those references which have no Payment Request set.\n
And set to `frappe.response` to show in the frontend for allocation.
"""
if not self.references:
return
matched_payment_requests = get_matched_payment_request_of_references(
[row for row in self.references if not row.payment_request]
)
if not matched_payment_requests:
return
frappe.response["matched_payment_requests"] = matched_payment_requests
@frappe.whitelist()
def allocate_amount_to_references(self, paid_amount, paid_amount_change, allocate_payment_amount):
"""
Allocate `Allocated Amount` and `Payment Request` against `Reference` based on `Paid Amount` and `Outstanding Amount`.\n
:param paid_amount: Paid Amount / Received Amount.
:param paid_amount_change: Flag to check if `Paid Amount` is changed or not.
:param allocate_payment_amount: Flag to allocate amount or not. (Payment Request is also dependent on this flag)
"""
if not self.references:
return
if not allocate_payment_amount:
for ref in self.references:
ref.allocated_amount = 0
return
# calculating outstanding amounts
precision = self.precision("paid_amount")
total_positive_outstanding_including_order = 0
total_negative_outstanding = 0
paid_amount -= sum(flt(d.amount, precision) for d in self.deductions)
for ref in self.references:
reference_outstanding_amount = ref.outstanding_amount
abs_outstanding_amount = abs(reference_outstanding_amount)
if reference_outstanding_amount > 0:
total_positive_outstanding_including_order += abs_outstanding_amount
else:
total_negative_outstanding += abs_outstanding_amount
# calculating allocated outstanding amounts
allocated_negative_outstanding = 0
allocated_positive_outstanding = 0
# checking party type and payment type
if (self.payment_type == "Receive" and self.party_type == "Customer") or (
self.payment_type == "Pay" and self.party_type in ("Supplier", "Employee")
):
if total_positive_outstanding_including_order > paid_amount:
remaining_outstanding = flt(
total_positive_outstanding_including_order - paid_amount, precision
)
allocated_negative_outstanding = min(remaining_outstanding, total_negative_outstanding)
allocated_positive_outstanding = paid_amount + allocated_negative_outstanding
elif self.party_type in ("Supplier", "Employee"):
if paid_amount > total_negative_outstanding:
if total_negative_outstanding == 0:
frappe.msgprint(
_("Cannot {0} from {1} without any negative outstanding invoice").format(
self.payment_type,
self.party_type,
)
)
else:
frappe.msgprint(
_("Paid Amount cannot be greater than total negative outstanding amount {0}").format(
total_negative_outstanding
)
)
return
else:
allocated_positive_outstanding = flt(total_negative_outstanding - paid_amount, precision)
allocated_negative_outstanding = paid_amount + min(
total_positive_outstanding_including_order, allocated_positive_outstanding
)
# inner function to set `allocated_amount` to those row which have no PR
def _allocation_to_unset_pr_row(
row, outstanding_amount, allocated_positive_outstanding, allocated_negative_outstanding
):
if outstanding_amount > 0 and allocated_positive_outstanding >= 0:
row.allocated_amount = min(allocated_positive_outstanding, outstanding_amount)
allocated_positive_outstanding = flt(
allocated_positive_outstanding - row.allocated_amount, precision
)
elif outstanding_amount < 0 and allocated_negative_outstanding:
row.allocated_amount = min(allocated_negative_outstanding, abs(outstanding_amount)) * -1
allocated_negative_outstanding = flt(
allocated_negative_outstanding - abs(row.allocated_amount), precision
)
return allocated_positive_outstanding, allocated_negative_outstanding
# allocate amount based on `paid_amount` is changed or not
if not paid_amount_change:
for ref in self.references:
allocated_positive_outstanding, allocated_negative_outstanding = _allocation_to_unset_pr_row(
ref,
ref.outstanding_amount,
allocated_positive_outstanding,
allocated_negative_outstanding,
)
allocate_open_payment_requests_to_references(self.references, self.precision("paid_amount"))
else:
payment_request_outstanding_amounts = (
get_payment_request_outstanding_set_in_references(self.references) or {}
)
references_outstanding_amounts = get_references_outstanding_amount(self.references) or {}
remaining_references_allocated_amounts = references_outstanding_amounts.copy()
# Re allocate amount to those references which have PR set (Higher priority)
for ref in self.references:
if not ref.payment_request:
continue
# fetch outstanding_amount of `Reference` (Payment Term) and `Payment Request` to allocate new amount
key = (ref.reference_doctype, ref.reference_name, ref.get("payment_term"))
reference_outstanding_amount = references_outstanding_amounts[key]
pr_outstanding_amount = payment_request_outstanding_amounts[ref.payment_request]
if reference_outstanding_amount > 0 and allocated_positive_outstanding >= 0:
# allocate amount according to outstanding amounts
outstanding_amounts = (
allocated_positive_outstanding,
reference_outstanding_amount,
pr_outstanding_amount,
)
ref.allocated_amount = min(outstanding_amounts)
# update amounts to track allocation
allocated_amount = ref.allocated_amount
allocated_positive_outstanding = flt(
allocated_positive_outstanding - allocated_amount, precision
)
remaining_references_allocated_amounts[key] = flt(
remaining_references_allocated_amounts[key] - allocated_amount, precision
)
payment_request_outstanding_amounts[ref.payment_request] = flt(
payment_request_outstanding_amounts[ref.payment_request] - allocated_amount, precision
)
elif reference_outstanding_amount < 0 and allocated_negative_outstanding:
# allocate amount according to outstanding amounts
outstanding_amounts = (
allocated_negative_outstanding,
abs(reference_outstanding_amount),
pr_outstanding_amount,
)
ref.allocated_amount = min(outstanding_amounts) * -1
# update amounts to track allocation
allocated_amount = abs(ref.allocated_amount)
allocated_negative_outstanding = flt(
allocated_negative_outstanding - allocated_amount, precision
)
remaining_references_allocated_amounts[key] += allocated_amount # negative amount
payment_request_outstanding_amounts[ref.payment_request] = flt(
payment_request_outstanding_amounts[ref.payment_request] - allocated_amount, precision
)
# Re allocate amount to those references which have no PR (Lower priority)
for ref in self.references:
if ref.payment_request:
continue
key = (ref.reference_doctype, ref.reference_name, ref.get("payment_term"))
reference_outstanding_amount = remaining_references_allocated_amounts[key]
allocated_positive_outstanding, allocated_negative_outstanding = _allocation_to_unset_pr_row(
ref,
reference_outstanding_amount,
allocated_positive_outstanding,
allocated_negative_outstanding,
)
@frappe.whitelist()
def set_matched_payment_requests(self, matched_payment_requests):
"""
Set `Payment Request` against `Reference` based on `matched_payment_requests`.\n
:param matched_payment_requests: List of tuple of matched Payment Requests.
---
Example: [(reference_doctype, reference_name, allocated_amount, payment_request), ...]
"""
if not self.references or not matched_payment_requests:
return
if isinstance(matched_payment_requests, str):
matched_payment_requests = json.loads(matched_payment_requests)
# modify matched_payment_requests
# like (reference_doctype, reference_name, allocated_amount): payment_request
payment_requests = {}
for row in matched_payment_requests:
key = tuple(row[:3])
payment_requests[key] = row[3]
for ref in self.references:
if ref.payment_request:
continue
key = (ref.reference_doctype, ref.reference_name, ref.allocated_amount)
if key in payment_requests:
ref.payment_request = payment_requests[key]
del payment_requests[key] # to avoid duplicate allocation
def get_matched_payment_request_of_references(references=None):
"""
Get those `Payment Requests` which are matched with `References`.\n
- Amount must be same.
- Only single `Payment Request` available for this amount.
Example: [(reference_doctype, reference_name, allocated_amount, payment_request), ...]
"""
if not references:
return
# to fetch matched rows
refs = {
(row.reference_doctype, row.reference_name, row.allocated_amount)
for row in references
if row.reference_doctype and row.reference_name and row.allocated_amount
}
if not refs:
return
PR = frappe.qb.DocType("Payment Request")
# query to group by reference_doctype, reference_name, outstanding_amount
subquery = (
frappe.qb.from_(PR)
.select(
PR.reference_doctype,
PR.reference_name,
PR.outstanding_amount.as_("allocated_amount"),
PR.name.as_("payment_request"),
Count("*").as_("count"),
)
.where(Tuple(PR.reference_doctype, PR.reference_name, PR.outstanding_amount).isin(refs))
.where(PR.status != "Paid")
.where(PR.docstatus == 1)
.groupby(PR.reference_doctype, PR.reference_name, PR.outstanding_amount)
)
# query to fetch matched rows which are single
matched_prs = (
frappe.qb.from_(subquery)
.select(
subquery.reference_doctype,
subquery.reference_name,
subquery.allocated_amount,
subquery.payment_request,
)
.where(subquery.count == 1)
.run()
)
return matched_prs if matched_prs else None
def get_references_outstanding_amount(references=None):
"""
Fetch accurate outstanding amount of `References`.\n
- If `Payment Term` is set, then fetch outstanding amount from `Payment Schedule`.
- If `Payment Term` is not set, then fetch outstanding amount from `References` it self.
Example: {(reference_doctype, reference_name, payment_term): outstanding_amount, ...}
"""
if not references:
return
refs_with_payment_term = get_outstanding_of_references_with_payment_term(references) or {}
refs_without_payment_term = get_outstanding_of_references_with_no_payment_term(references) or {}
return {**refs_with_payment_term, **refs_without_payment_term}
def get_outstanding_of_references_with_payment_term(references=None):
"""
Fetch outstanding amount of `References` which have `Payment Term` set.\n
Example: {(reference_doctype, reference_name, payment_term): outstanding_amount, ...}
"""
if not references:
return
refs = {
(row.reference_doctype, row.reference_name, row.payment_term)
for row in references
if row.reference_doctype and row.reference_name and row.payment_term
}
if not refs:
return
PS = frappe.qb.DocType("Payment Schedule")
response = (
frappe.qb.from_(PS)
.select(PS.parenttype, PS.parent, PS.payment_term, PS.outstanding)
.where(Tuple(PS.parenttype, PS.parent, PS.payment_term).isin(refs))
).run(as_dict=True)
if not response:
return
return {(row.parenttype, row.parent, row.payment_term): row.outstanding for row in response}
def get_outstanding_of_references_with_no_payment_term(references):
"""
Fetch outstanding amount of `References` which have no `Payment Term` set.\n
- Fetch outstanding amount from `References` it self.
Note: `None` is used for allocation of `Payment Request`
Example: {(reference_doctype, reference_name, None): outstanding_amount, ...}
"""
if not references:
return
outstanding_amounts = {}
for ref in references:
if ref.payment_term:
continue
key = (ref.reference_doctype, ref.reference_name, None)
if key not in outstanding_amounts:
outstanding_amounts[key] = ref.outstanding_amount
return outstanding_amounts
def get_payment_request_outstanding_set_in_references(references=None):
"""
Fetch outstanding amount of `Payment Request` which are set in `References`.\n
Example: {payment_request: outstanding_amount, ...}
"""
if not references:
return
referenced_payment_requests = {row.payment_request for row in references if row.payment_request}
if not referenced_payment_requests:
return
PR = frappe.qb.DocType("Payment Request")
response = (
frappe.qb.from_(PR)
.select(PR.name, PR.outstanding_amount)
.where(PR.name.isin(referenced_payment_requests))
).run()
return dict(response) if response else None
def validate_inclusive_tax(tax, doc):
def _on_previous_row_error(row_range):
@@ -1740,7 +2260,7 @@ def get_outstanding_reference_documents(args, validate=False):
d["bill_no"] = frappe.db.get_value(d.voucher_type, d.voucher_no, "bill_no")
# Get negative outstanding sales /purchase invoices
if args.get("party_type") != "Employee" and not args.get("voucher_no"):
if args.get("party_type") != "Employee":
negative_outstanding_invoices = get_negative_outstanding_invoices(
args.get("party_type"),
args.get("party"),
@@ -2046,7 +2566,9 @@ def get_party_details(company, party_type, party, date, cost_center=None):
account_balance = get_balance_on(party_account, date, cost_center=cost_center)
_party_name = "title" if party_type == "Shareholder" else party_type.lower() + "_name"
party_name = frappe.db.get_value(party_type, party, _party_name)
party_balance = get_balance_on(party_type=party_type, party=party, cost_center=cost_center)
party_balance = get_balance_on(
party_type=party_type, party=party, company=company, cost_center=cost_center
)
if party_type in ["Customer", "Supplier"]:
party_bank_account = get_party_bank_account(party_type, party)
bank_account = get_default_company_bank_account(company, party_type, party)
@@ -2224,6 +2746,8 @@ def get_payment_entry(
party_type=None,
payment_type=None,
reference_date=None,
ignore_permissions=False,
created_from_payment_request=False,
):
doc = frappe.get_doc(dt, dn)
over_billing_allowance = frappe.db.get_single_value("Accounts Settings", "over_billing_allowance")
@@ -2359,9 +2883,6 @@ def get_payment_entry(
update_accounting_dimensions(pe, doc)
if party_account and bank:
pe.set_exchange_rate(ref_doc=doc)
pe.set_amounts()
if discount_amount:
base_total_discount_loss = 0
if frappe.db.get_single_value("Accounts Settings", "book_tax_discount_loss"):
@@ -2371,11 +2892,183 @@ def get_payment_entry(
pe, doc, discount_amount, base_total_discount_loss, party_account_currency
)
pe.set_difference_amount()
pe.set_exchange_rate(ref_doc=doc)
pe.set_amounts()
# If PE is created from PR directly, then no need to find open PRs for the references
if not created_from_payment_request:
allocate_open_payment_requests_to_references(pe.references, pe.precision("paid_amount"))
return pe
def get_open_payment_requests_for_references(references=None):
"""
Fetch all unpaid Payment Requests for the references. \n
- Each reference can have multiple Payment Requests. \n
Example: {("Sales Invoice", "SINV-00001"): {"PREQ-00001": 1000, "PREQ-00002": 2000}}
"""
if not references:
return
refs = {
(row.reference_doctype, row.reference_name)
for row in references
if row.reference_doctype and row.reference_name and row.allocated_amount
}
if not refs:
return
PR = frappe.qb.DocType("Payment Request")
response = (
frappe.qb.from_(PR)
.select(PR.name, PR.reference_doctype, PR.reference_name, PR.outstanding_amount)
.where(Tuple(PR.reference_doctype, PR.reference_name).isin(list(refs)))
.where(PR.status != "Paid")
.where(PR.docstatus == 1)
.where(PR.outstanding_amount > 0) # to avoid old PRs with 0 outstanding amount
.orderby(Coalesce(PR.transaction_date, PR.creation), order=frappe.qb.asc)
).run(as_dict=True)
if not response:
return
reference_payment_requests = {}
for row in response:
key = (row.reference_doctype, row.reference_name)
if key not in reference_payment_requests:
reference_payment_requests[key] = {row.name: row.outstanding_amount}
else:
reference_payment_requests[key][row.name] = row.outstanding_amount
return reference_payment_requests
def allocate_open_payment_requests_to_references(references=None, precision=None):
"""
Allocate unpaid Payment Requests to the references. \n
---
- Allocation based on below factors
- Reference Allocated Amount
- Reference Outstanding Amount (With Payment Terms or without Payment Terms)
- Reference Payment Request's outstanding amount
---
- Allocation based on below scenarios
- Reference's Allocated Amount == Payment Request's Outstanding Amount
- Allocate the Payment Request to the reference
- This PR will not be allocated further
- Reference's Allocated Amount < Payment Request's Outstanding Amount
- Allocate the Payment Request to the reference
- Reduce the PR's outstanding amount by the allocated amount
- This PR can be allocated further
- Reference's Allocated Amount > Payment Request's Outstanding Amount
- Allocate the Payment Request to the reference
- Reduce Allocated Amount of the reference by the PR's outstanding amount
- Create a new row for the remaining amount until the Allocated Amount is 0
- Allocate PR if available
---
- Note:
- Priority is given to the first Payment Request of respective references.
- Single Reference can have multiple rows.
- With Payment Terms or without Payment Terms
- With Payment Request or without Payment Request
"""
if not references:
return
# get all unpaid payment requests for the references
references_open_payment_requests = get_open_payment_requests_for_references(references)
if not references_open_payment_requests:
return
if not precision:
precision = references[0].precision("allocated_amount")
# to manage new rows
row_number = 1
MOVE_TO_NEXT_ROW = 1
TO_SKIP_NEW_ROW = 2
while row_number <= len(references):
row = references[row_number - 1]
reference_key = (row.reference_doctype, row.reference_name)
# update the idx to maintain the order
row.idx = row_number
# unpaid payment requests for the reference
reference_payment_requests = references_open_payment_requests.get(reference_key)
if not reference_payment_requests:
row_number += MOVE_TO_NEXT_ROW # to move to next reference row
continue
# get the first payment request and its outstanding amount
payment_request, pr_outstanding_amount = next(iter(reference_payment_requests.items()))
allocated_amount = row.allocated_amount
# allocate the payment request to the reference and PR's outstanding amount
row.payment_request = payment_request
if pr_outstanding_amount == allocated_amount:
del reference_payment_requests[payment_request]
row_number += MOVE_TO_NEXT_ROW
elif pr_outstanding_amount > allocated_amount:
# reduce the outstanding amount of the payment request
reference_payment_requests[payment_request] -= allocated_amount
row_number += MOVE_TO_NEXT_ROW
else:
# split the reference row to allocate the remaining amount
del reference_payment_requests[payment_request]
row.allocated_amount = pr_outstanding_amount
allocated_amount = flt(allocated_amount - pr_outstanding_amount, precision)
# set the remaining amount to the next row
while allocated_amount:
# create a new row for the remaining amount
new_row = frappe.copy_doc(row)
references.insert(row_number, new_row)
# get the first payment request and its outstanding amount
payment_request, pr_outstanding_amount = next(
iter(reference_payment_requests.items()), (None, None)
)
# update new row
new_row.idx = row_number + 1
new_row.payment_request = payment_request
new_row.allocated_amount = min(
pr_outstanding_amount if pr_outstanding_amount else allocated_amount, allocated_amount
)
if not payment_request or not pr_outstanding_amount:
row_number += TO_SKIP_NEW_ROW
break
elif pr_outstanding_amount == allocated_amount:
del reference_payment_requests[payment_request]
row_number += TO_SKIP_NEW_ROW
break
elif pr_outstanding_amount > allocated_amount:
reference_payment_requests[payment_request] -= allocated_amount
row_number += TO_SKIP_NEW_ROW
break
else:
allocated_amount = flt(allocated_amount - pr_outstanding_amount, precision)
del reference_payment_requests[payment_request]
row_number += MOVE_TO_NEXT_ROW
def update_accounting_dimensions(pe, doc):
"""
Updates accounting dimensions in Payment Entry based on the accounting dimensions in the reference document
@@ -2547,13 +3240,14 @@ def set_pending_discount_loss(pe, doc, discount_amount, base_total_discount_loss
book_tax_loss = frappe.db.get_single_value("Accounts Settings", "book_tax_discount_loss")
account_type = "round_off_account" if book_tax_loss else "default_discount_account"
pe.set_gain_or_loss(
account_details={
pe.append(
"deductions",
{
"account": frappe.get_cached_value("Company", pe.company, account_type),
"cost_center": pe.cost_center
or frappe.get_cached_value("Company", pe.company, "cost_center"),
"amount": discount_amount * positive_negative,
}
},
)

View File

@@ -479,16 +479,9 @@ class TestPaymentEntry(FrappeTestCase):
self.assertEqual(pe.deductions[0].account, "Write Off - _TC")
# Exchange loss
self.assertEqual(pe.difference_amount, 300.0)
pe.append(
"deductions",
{
"account": "_Test Exchange Gain/Loss - _TC",
"cost_center": "_Test Cost Center - _TC",
"amount": 300.0,
},
)
self.assertEqual(pe.deductions[-1].amount, 300.0)
pe.deductions[-1].account = "_Test Exchange Gain/Loss - _TC"
pe.deductions[-1].cost_center = "_Test Cost Center - _TC"
pe.insert()
pe.submit()
@@ -552,16 +545,10 @@ class TestPaymentEntry(FrappeTestCase):
pe.reference_no = "1"
pe.reference_date = "2016-01-01"
self.assertEqual(pe.difference_amount, 100)
self.assertEqual(pe.deductions[0].amount, 100)
pe.deductions[0].account = "_Test Exchange Gain/Loss - _TC"
pe.deductions[0].cost_center = "_Test Cost Center - _TC"
pe.append(
"deductions",
{
"account": "_Test Exchange Gain/Loss - _TC",
"cost_center": "_Test Cost Center - _TC",
"amount": 100,
},
)
pe.insert()
pe.submit()
@@ -654,16 +641,9 @@ class TestPaymentEntry(FrappeTestCase):
pe.set_exchange_rate()
pe.set_amounts()
self.assertEqual(pe.difference_amount, 500)
pe.append(
"deductions",
{
"account": "_Test Exchange Gain/Loss - _TC",
"cost_center": "_Test Cost Center - _TC",
"amount": 500,
},
)
self.assertEqual(pe.deductions[0].amount, 500)
pe.deductions[0].account = "_Test Exchange Gain/Loss - _TC"
pe.deductions[0].cost_center = "_Test Cost Center - _TC"
pe.insert()
pe.submit()
@@ -956,6 +936,53 @@ class TestPaymentEntry(FrappeTestCase):
self.assertEqual(flt(expected_party_balance), party_balance)
self.assertEqual(flt(expected_party_account_balance, 2), flt(party_account_balance, 2))
def test_gl_of_multi_currency_payment_transaction(self):
from erpnext.setup.doctype.currency_exchange.test_currency_exchange import (
save_new_records,
test_records,
)
save_new_records(test_records)
paid_from = create_account(
parent_account="Current Liabilities - _TC",
account_name="_Test Cash USD",
company="_Test Company",
account_type="Cash",
account_currency="USD",
)
payment_entry = create_payment_entry(
party="_Test Supplier USD",
paid_from=paid_from,
paid_to="_Test Payable USD - _TC",
paid_amount=100,
save=True,
)
payment_entry.source_exchange_rate = 84.4
payment_entry.target_exchange_rate = 84.4
payment_entry.save()
payment_entry = payment_entry.submit()
gle = qb.DocType("GL Entry")
gl_entries = (
qb.from_(gle)
.select(
gle.account,
gle.debit,
gle.credit,
gle.debit_in_account_currency,
gle.credit_in_account_currency,
gle.debit_in_transaction_currency,
gle.credit_in_transaction_currency,
)
.orderby(gle.account)
.where(gle.voucher_no == payment_entry.name)
.run()
)
expected_gl_entries = (
(paid_from, 0.0, 8440.0, 0.0, 100.0, 0.0, 100.0),
("_Test Payable USD - _TC", 8440.0, 0.0, 100.0, 0.0, 100.0, 0.0),
)
self.assertEqual(gl_entries, expected_gl_entries)
def test_multi_currency_payment_entry_with_taxes(self):
payment_entry = create_payment_entry(
party="_Test Supplier USD", paid_to="_Test Payable USD - _TC", save=True
@@ -1791,6 +1818,79 @@ class TestPaymentEntry(FrappeTestCase):
# 'Is Opening' should always be 'No' for normal advance payments
self.assertEqual(gl_with_opening_set, [])
@change_settings("Accounts Settings", {"delete_linked_ledger_entries": 1})
def test_delete_linked_exchange_gain_loss_journal(self):
from erpnext.accounts.doctype.account.test_account import create_account
from erpnext.accounts.doctype.opening_invoice_creation_tool.test_opening_invoice_creation_tool import (
make_customer,
)
debtors = create_account(
account_name="Debtors USD",
parent_account="Accounts Receivable - _TC",
company="_Test Company",
account_currency="USD",
account_type="Receivable",
)
# create a customer
customer = make_customer(customer="_Test Party USD")
cust_doc = frappe.get_doc("Customer", customer)
cust_doc.default_currency = "USD"
test_account_details = {
"company": "_Test Company",
"account": debtors,
}
cust_doc.append("accounts", test_account_details)
cust_doc.save()
# create a sales invoice
si = create_sales_invoice(
customer=customer,
currency="USD",
conversion_rate=83.970000000,
debit_to=debtors,
do_not_save=1,
)
si.party_account_currency = "USD"
si.save()
si.submit()
# create a payment entry for the invoice
pe = get_payment_entry("Sales Invoice", si.name)
pe.reference_no = "1"
pe.reference_date = frappe.utils.nowdate()
pe.paid_amount = 100
pe.source_exchange_rate = 90
pe.append(
"deductions",
{
"account": "_Test Exchange Gain/Loss - _TC",
"cost_center": "_Test Cost Center - _TC",
"amount": 2710,
},
)
pe.save()
pe.submit()
# check creation of journal entry
jv = frappe.get_all(
"Journal Entry Account",
{"reference_type": pe.doctype, "reference_name": pe.name, "docstatus": 1},
pluck="parent",
)
self.assertTrue(jv)
# check cancellation of payment entry and journal entry
pe.cancel()
self.assertTrue(pe.docstatus == 2)
self.assertTrue(frappe.db.get_value("Journal Entry", {"name": jv[0]}, "docstatus") == 2)
# check deletion of payment entry and journal entry
pe.delete()
self.assertRaises(frappe.DoesNotExistError, frappe.get_doc, pe.doctype, pe.name)
self.assertRaises(frappe.DoesNotExistError, frappe.get_doc, "Journal Entry", jv[0])
def create_payment_entry(**args):
payment_entry = frappe.new_doc("Payment Entry")

View File

@@ -9,6 +9,7 @@
"cost_center",
"amount",
"column_break_2",
"is_exchange_gain_loss",
"description"
],
"fields": [
@@ -45,12 +46,20 @@
"fieldname": "description",
"fieldtype": "Small Text",
"label": "Description"
},
{
"default": "0",
"depends_on": "eval:doc.is_exchange_gain_loss",
"fieldname": "is_exchange_gain_loss",
"fieldtype": "Check",
"label": "Is Exchange Gain / Loss?",
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2023-03-06 07:11:57.739619",
"modified": "2024-11-05 16:07:47.307971",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Entry Deduction",

View File

@@ -18,6 +18,7 @@ class PaymentEntryDeduction(Document):
amount: DF.Currency
cost_center: DF.Link
description: DF.SmallText | None
is_exchange_gain_loss: DF.Check
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data

View File

@@ -10,6 +10,7 @@
"due_date",
"bill_no",
"payment_term",
"payment_term_outstanding",
"account_type",
"payment_type",
"column_break_4",
@@ -18,7 +19,9 @@
"allocated_amount",
"exchange_rate",
"exchange_gain_loss",
"account"
"account",
"payment_request",
"payment_request_outstanding"
],
"fields": [
{
@@ -120,12 +123,33 @@
"fieldname": "payment_type",
"fieldtype": "Data",
"label": "Payment Type"
},
{
"fieldname": "payment_request",
"fieldtype": "Link",
"label": "Payment Request",
"options": "Payment Request"
},
{
"depends_on": "eval: doc.payment_term",
"fieldname": "payment_term_outstanding",
"fieldtype": "Float",
"label": "Payment Term Outstanding",
"read_only": 1
},
{
"depends_on": "eval: doc.payment_request && doc.payment_request_outstanding",
"fieldname": "payment_request_outstanding",
"fieldtype": "Float",
"is_virtual": 1,
"label": "Payment Request Outstanding",
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2024-04-05 09:44:08.310593",
"modified": "2024-09-16 18:11:50.019343",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Entry Reference",

View File

@@ -1,7 +1,7 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
from frappe.model.document import Document
@@ -25,11 +25,19 @@ class PaymentEntryReference(Document):
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
payment_request: DF.Link | None
payment_request_outstanding: DF.Float
payment_term: DF.Link | None
payment_term_outstanding: DF.Float
payment_type: DF.Data | None
reference_doctype: DF.Link
reference_name: DF.DynamicLink
total_amount: DF.Float
# end: auto-generated types
pass
@property
def payment_request_outstanding(self):
if not self.payment_request:
return
return frappe.db.get_value("Payment Request", self.payment_request, "outstanding_amount")

View File

@@ -153,10 +153,7 @@ class PaymentReconciliation(Document):
self.add_payment_entries(non_reconciled_payments)
def get_payment_entries(self):
if self.default_advance_account:
party_account = [self.receivable_payable_account, self.default_advance_account]
else:
party_account = [self.receivable_payable_account]
party_account = [self.receivable_payable_account]
order_doctype = "Sales Order" if self.party_type == "Customer" else "Purchase Order"
condition = frappe._dict(
@@ -187,6 +184,7 @@ class PaymentReconciliation(Document):
self.party,
party_account,
order_doctype,
default_advance_account=self.default_advance_account,
against_all_orders=True,
limit=self.payment_limit,
condition=condition,
@@ -211,12 +209,14 @@ class PaymentReconciliation(Document):
if self.get("cost_center"):
conditions.append(jea.cost_center == self.cost_center)
dr_or_cr = (
"credit_in_account_currency"
if erpnext.get_party_account_type(self.party_type) == "Receivable"
else "debit_in_account_currency"
)
conditions.append(jea[dr_or_cr].gt(0))
account_type = erpnext.get_party_account_type(self.party_type)
if account_type == "Receivable":
dr_or_cr = jea.credit_in_account_currency - jea.debit_in_account_currency
elif account_type == "Payable":
dr_or_cr = jea.debit_in_account_currency - jea.credit_in_account_currency
conditions.append(dr_or_cr.gt(0))
if self.bank_cash_account:
conditions.append(jea.against_account.like(f"%%{self.bank_cash_account}%%"))
@@ -231,7 +231,7 @@ class PaymentReconciliation(Document):
je.posting_date,
je.remark.as_("remarks"),
jea.name.as_("reference_row"),
jea[dr_or_cr].as_("amount"),
dr_or_cr.as_("amount"),
jea.is_advance,
jea.exchange_rate,
jea.account_currency.as_("currency"),
@@ -323,6 +323,7 @@ class PaymentReconciliation(Document):
"posting_date": inv.posting_date,
"currency": inv.currency,
"cost_center": inv.cost_center,
"remarks": inv.remarks,
}
)
)
@@ -370,6 +371,10 @@ class PaymentReconciliation(Document):
if self.invoice_limit:
non_reconciled_invoices = non_reconciled_invoices[: self.invoice_limit]
non_reconciled_invoices = sorted(
non_reconciled_invoices, key=lambda k: k["posting_date"] or getdate(nowdate())
)
self.add_invoice_entries(non_reconciled_invoices)
def add_invoice_entries(self, non_reconciled_invoices):

View File

@@ -5,7 +5,7 @@
import frappe
from frappe import qb
from frappe.tests.utils import FrappeTestCase, change_settings
from frappe.utils import add_days, flt, nowdate
from frappe.utils import add_days, add_years, flt, getdate, nowdate, today
from erpnext import get_default_cost_center
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
@@ -13,6 +13,7 @@ from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_pay
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.party import get_party_account
from erpnext.accounts.utils import get_fiscal_year
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
from erpnext.stock.doctype.item.test_item import create_item
@@ -631,6 +632,42 @@ class TestPaymentReconciliation(FrappeTestCase):
self.assertEqual(len(pr.get("invoices")), 0)
self.assertEqual(len(pr.get("payments")), 0)
def test_negative_debit_or_credit_journal_against_invoice(self):
transaction_date = nowdate()
amount = 100
si = self.create_sales_invoice(qty=1, rate=amount, posting_date=transaction_date)
# credit debtors account to record a payment
je = self.create_journal_entry(self.bank, self.debit_to, amount, transaction_date)
je.accounts[1].party_type = "Customer"
je.accounts[1].party = self.customer
je.accounts[1].credit_in_account_currency = 0
je.accounts[1].debit_in_account_currency = -1 * amount
je.save()
je.submit()
pr = self.create_payment_reconciliation()
pr.get_unreconciled_entries()
invoices = [x.as_dict() for x in pr.get("invoices")]
payments = [x.as_dict() for x in pr.get("payments")]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
# Difference amount should not be calculated for base currency accounts
for row in pr.allocation:
self.assertEqual(flt(row.get("difference_amount")), 0.0)
pr.reconcile()
# assert outstanding
si.reload()
self.assertEqual(si.status, "Paid")
self.assertEqual(si.outstanding_amount, 0)
# check PR tool output
self.assertEqual(len(pr.get("invoices")), 0)
self.assertEqual(len(pr.get("payments")), 0)
def test_journal_against_journal(self):
transaction_date = nowdate()
sales = "Sales - _PR"
@@ -953,6 +990,100 @@ class TestPaymentReconciliation(FrappeTestCase):
frappe.db.get_value("Journal Entry", jea_parent.parent, "voucher_type"), "Exchange Gain Or Loss"
)
def test_difference_amount_via_negative_debit_or_credit_journal_entry(self):
# Make Sale Invoice
si = self.create_sales_invoice(
qty=1, rate=100, posting_date=nowdate(), do_not_save=True, do_not_submit=True
)
si.customer = self.customer4
si.currency = "EUR"
si.conversion_rate = 85
si.debit_to = self.debtors_eur
si.save().submit()
# Make payment using Journal Entry
je1 = self.create_journal_entry("HDFC - _PR", self.debtors_eur, 100, nowdate())
je1.multi_currency = 1
je1.accounts[0].exchange_rate = 1
je1.accounts[0].credit_in_account_currency = -8000
je1.accounts[0].credit = -8000
je1.accounts[0].debit_in_account_currency = 0
je1.accounts[0].debit = 0
je1.accounts[1].party_type = "Customer"
je1.accounts[1].party = self.customer4
je1.accounts[1].exchange_rate = 80
je1.accounts[1].credit_in_account_currency = 100
je1.accounts[1].credit = 8000
je1.accounts[1].debit_in_account_currency = 0
je1.accounts[1].debit = 0
je1.save()
je1.submit()
je2 = self.create_journal_entry("HDFC - _PR", self.debtors_eur, 200, nowdate())
je2.multi_currency = 1
je2.accounts[0].exchange_rate = 1
je2.accounts[0].credit_in_account_currency = -16000
je2.accounts[0].credit = -16000
je2.accounts[0].debit_in_account_currency = 0
je2.accounts[0].debit = 0
je2.accounts[1].party_type = "Customer"
je2.accounts[1].party = self.customer4
je2.accounts[1].exchange_rate = 80
je2.accounts[1].credit_in_account_currency = 200
je1.accounts[1].credit = 16000
je1.accounts[1].debit_in_account_currency = 0
je1.accounts[1].debit = 0
je2.save()
je2.submit()
pr = self.create_payment_reconciliation()
pr.party = self.customer4
pr.receivable_payable_account = self.debtors_eur
pr.get_unreconciled_entries()
self.assertEqual(len(pr.invoices), 1)
self.assertEqual(len(pr.payments), 2)
# Test exact payment allocation
invoices = [x.as_dict() for x in pr.invoices]
payments = [pr.payments[0].as_dict()]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
self.assertEqual(pr.allocation[0].allocated_amount, 100)
self.assertEqual(pr.allocation[0].difference_amount, -500)
# Test partial payment allocation (with excess payment entry)
pr.set("allocation", [])
pr.get_unreconciled_entries()
invoices = [x.as_dict() for x in pr.invoices]
payments = [pr.payments[1].as_dict()]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
pr.allocation[0].difference_account = "Exchange Gain/Loss - _PR"
self.assertEqual(pr.allocation[0].allocated_amount, 100)
self.assertEqual(pr.allocation[0].difference_amount, -500)
# Check if difference journal entry gets generated for difference amount after reconciliation
pr.reconcile()
total_credit_amount = frappe.db.get_all(
"Journal Entry Account",
{"account": self.debtors_eur, "docstatus": 1, "reference_name": si.name},
"sum(credit) as amount",
group_by="reference_name",
)[0].amount
# total credit includes the exchange gain/loss amount
self.assertEqual(flt(total_credit_amount, 2), 8500)
jea_parent = frappe.db.get_all(
"Journal Entry Account",
filters={"account": self.debtors_eur, "docstatus": 1, "reference_name": si.name, "credit": 500},
fields=["parent"],
)[0]
self.assertEqual(
frappe.db.get_value("Journal Entry", jea_parent.parent, "voucher_type"), "Exchange Gain Or Loss"
)
def test_difference_amount_via_payment_entry(self):
# Make Sale Invoice
si = self.create_sales_invoice(
@@ -1845,6 +1976,78 @@ class TestPaymentReconciliation(FrappeTestCase):
self.assertEqual(len(pr.invoices), 1)
self.assertEqual(len(pr.payments), 1)
def test_reconciliation_on_closed_period_payment(self):
# create backdated fiscal year
first_fy_start_date = frappe.db.get_value("Fiscal Year", {"disabled": 0}, "min(year_start_date)")
prev_fy_start_date = add_years(first_fy_start_date, -1)
prev_fy_end_date = add_days(first_fy_start_date, -1)
create_fiscal_year(
company=self.company, year_start_date=prev_fy_start_date, year_end_date=prev_fy_end_date
)
# make journal entry for previous year
je_1 = frappe.new_doc("Journal Entry")
je_1.posting_date = add_days(prev_fy_start_date, 20)
je_1.company = self.company
je_1.user_remark = "test"
je_1.set(
"accounts",
[
{
"account": self.debit_to,
"cost_center": self.cost_center,
"party_type": "Customer",
"party": self.customer,
"debit_in_account_currency": 0,
"credit_in_account_currency": 1000,
},
{
"account": self.bank,
"cost_center": self.sub_cc.name,
"credit_in_account_currency": 0,
"debit_in_account_currency": 500,
},
{
"account": self.cash,
"cost_center": self.sub_cc.name,
"credit_in_account_currency": 0,
"debit_in_account_currency": 500,
},
],
)
je_1.submit()
# make period closing voucher
pcv = make_period_closing_voucher(
company=self.company, cost_center=self.cost_center, posting_date=prev_fy_end_date
)
pcv.reload()
# check if period closing voucher is completed
self.assertEqual(pcv.gle_processing_status, "Completed")
# make journal entry for active year
je_2 = self.create_journal_entry(
acc1=self.debit_to, acc2=self.income_account, amount=1000, posting_date=today()
)
je_2.accounts[0].party_type = "Customer"
je_2.accounts[0].party = self.customer
je_2.submit()
# process reconciliation on closed period payment
pr = self.create_payment_reconciliation(party_is_customer=True)
pr.from_invoice_date = pr.to_invoice_date = pr.from_payment_date = pr.to_payment_date = None
pr.get_unreconciled_entries()
invoices = [invoice.as_dict() for invoice in pr.invoices]
payments = [payment.as_dict() for payment in pr.payments]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
pr.reconcile()
je_1.reload()
je_2.reload()
# check whether the payment reconciliation is done on the closed period
self.assertEqual(pr.get("invoices"), [])
self.assertEqual(pr.get("payments"), [])
def make_customer(customer_name, currency=None):
if not frappe.db.exists("Customer", customer_name):
@@ -1872,3 +2075,63 @@ def make_supplier(supplier_name, currency=None):
return supplier.name
else:
return supplier_name
def create_fiscal_year(company, year_start_date, year_end_date):
fy_docname = frappe.db.exists(
"Fiscal Year", {"year_start_date": year_start_date, "year_end_date": year_end_date}
)
if not fy_docname:
fy_doc = frappe.get_doc(
{
"doctype": "Fiscal Year",
"year": f"{getdate(year_start_date).year}-{getdate(year_end_date).year}",
"year_start_date": year_start_date,
"year_end_date": year_end_date,
"companies": [{"company": company}],
}
).save()
return fy_doc
else:
fy_doc = frappe.get_doc("Fiscal Year", fy_docname)
if not frappe.db.exists("Fiscal Year Company", {"parent": fy_docname, "company": company}):
fy_doc.append("companies", {"company": company})
fy_doc.save()
return fy_doc
def make_period_closing_voucher(company, cost_center, posting_date=None, submit=True):
from erpnext.accounts.doctype.account.test_account import create_account
parent_account = frappe.db.get_value(
"Account", {"company": company, "account_name": "Current Liabilities", "is_group": 1}, "name"
)
surplus_account = create_account(
account_name="Reserve and Surplus",
is_group=0,
company=company,
root_type="Liability",
report_type="Balance Sheet",
account_currency="INR",
parent_account=parent_account,
doctype="Account",
)
fy = get_fiscal_year(posting_date, company=company)
pcv = frappe.get_doc(
{
"doctype": "Period Closing Voucher",
"transaction_date": posting_date or today(),
"period_start_date": fy[1],
"period_end_date": fy[2],
"company": company,
"fiscal_year": fy[0],
"cost_center": cost_center,
"closing_account_head": surplus_account,
"remarks": "test",
}
)
pcv.insert()
if submit:
pcv.submit()
return pcv

View File

@@ -14,7 +14,7 @@
"amount",
"difference_amount",
"sec_break1",
"remark",
"remarks",
"currency",
"exchange_rate",
"cost_center"
@@ -74,12 +74,6 @@
"fieldname": "sec_break1",
"fieldtype": "Section Break"
},
{
"fieldname": "remark",
"fieldtype": "Small Text",
"label": "Remark",
"read_only": 1
},
{
"fieldname": "currency",
"fieldtype": "Link",
@@ -105,12 +99,18 @@
"fieldtype": "Link",
"label": "Cost Center",
"options": "Cost Center"
},
{
"fieldname": "remarks",
"fieldtype": "Small Text",
"label": "Remarks",
"read_only": 1
}
],
"is_virtual": 1,
"istable": 1,
"links": [],
"modified": "2023-11-17 17:33:34.818530",
"modified": "2024-10-29 16:24:43.021230",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Reconciliation Payment",

View File

@@ -27,7 +27,7 @@ class PaymentReconciliationPayment(Document):
reference_name: DF.DynamicLink | None
reference_row: DF.Data | None
reference_type: DF.Link | None
remark: DF.SmallText | None
remarks: DF.SmallText | None
# end: auto-generated types
@staticmethod

View File

@@ -48,8 +48,8 @@ frappe.ui.form.on("Payment Request", "refresh", function (frm) {
}
if (
(!frm.doc.payment_gateway_account || frm.doc.payment_request_type == "Outward") &&
frm.doc.status == "Initiated"
frm.doc.payment_request_type == "Outward" &&
["Initiated", "Partially Paid"].includes(frm.doc.status)
) {
frm.add_custom_button(__("Create Payment Entry"), function () {
frappe.call({

View File

@@ -9,18 +9,22 @@
"transaction_date",
"column_break_2",
"naming_series",
"company",
"mode_of_payment",
"party_details",
"party_type",
"party",
"party_name",
"column_break_4",
"reference_doctype",
"reference_name",
"transaction_details",
"grand_total",
"currency",
"is_a_subscription",
"column_break_18",
"currency",
"outstanding_amount",
"party_account_currency",
"subscription_section",
"subscription_plans",
"bank_account_details",
@@ -68,6 +72,7 @@
{
"fieldname": "transaction_date",
"fieldtype": "Date",
"in_preview": 1,
"label": "Transaction Date"
},
{
@@ -132,7 +137,8 @@
"no_copy": 1,
"options": "reference_doctype",
"print_hide": 1,
"read_only": 1
"read_only": 1,
"search_index": 1
},
{
"fieldname": "transaction_details",
@@ -140,12 +146,14 @@
"label": "Transaction Details"
},
{
"description": "Amount in customer's currency",
"description": "Amount in transaction currency",
"fieldname": "grand_total",
"fieldtype": "Currency",
"in_preview": 1,
"label": "Amount",
"non_negative": 1,
"options": "currency"
"options": "currency",
"reqd": 1
},
{
"default": "0",
@@ -390,13 +398,44 @@
"options": "Payment Request",
"print_hide": 1,
"read_only": 1
},
{
"depends_on": "eval: doc.docstatus === 1",
"description": "Amount in party's bank account currency",
"fieldname": "outstanding_amount",
"fieldtype": "Currency",
"in_preview": 1,
"label": "Outstanding Amount",
"non_negative": 1,
"options": "party_account_currency",
"read_only": 1
},
{
"fieldname": "company",
"fieldtype": "Link",
"label": "Company",
"options": "Company",
"read_only": 1
},
{
"fieldname": "party_account_currency",
"fieldtype": "Link",
"label": "Party Account Currency",
"options": "Currency",
"read_only": 1
},
{
"fieldname": "party_name",
"fieldtype": "Data",
"label": "Party Name",
"read_only": 1
}
],
"in_create": 1,
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2024-06-20 13:54:55.245774",
"modified": "2024-10-23 12:23:40.117336",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Request",
@@ -431,7 +470,8 @@
"write": 1
}
],
"show_preview_popup": 1,
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}
}

View File

@@ -1,11 +1,13 @@
import json
import frappe
from frappe import _
from frappe import _, qb
from frappe.model.document import Document
from frappe.query_builder.functions import Abs, Sum
from frappe.utils import flt, nowdate
from frappe.utils.background_jobs import enqueue
from erpnext import get_company_currency
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
)
@@ -15,9 +17,18 @@ from erpnext.accounts.doctype.payment_entry.payment_entry import (
)
from erpnext.accounts.doctype.subscription_plan.subscription_plan import get_plan_rate
from erpnext.accounts.party import get_party_account, get_party_bank_account
from erpnext.accounts.utils import get_account_currency
from erpnext.accounts.utils import get_account_currency, get_currency_precision
from erpnext.utilities import payment_app_import_guard
ALLOWED_DOCTYPES_FOR_PAYMENT_REQUEST = [
"Sales Order",
"Purchase Order",
"Sales Invoice",
"Purchase Invoice",
"POS Invoice",
"Fees",
]
def _get_payment_gateway_controller(*args, **kwargs):
with payment_app_import_guard():
@@ -45,6 +56,7 @@ class PaymentRequest(Document):
bank_account: DF.Link | None
bank_account_no: DF.ReadOnly | None
branch_code: DF.ReadOnly | None
company: DF.Link | None
cost_center: DF.Link | None
currency: DF.Link | None
email_to: DF.Data | None
@@ -56,16 +68,19 @@ class PaymentRequest(Document):
mode_of_payment: DF.Link | None
mute_email: DF.Check
naming_series: DF.Literal["ACC-PRQ-.YYYY.-"]
outstanding_amount: DF.Currency
party: DF.DynamicLink | None
party_account_currency: DF.Link | None
party_name: DF.Data | None
party_type: DF.Link | None
payment_account: DF.ReadOnly | None
payment_channel: DF.Literal["", "Email", "Phone"]
payment_channel: DF.Literal["", "Email", "Phone", "Other"]
payment_gateway: DF.ReadOnly | None
payment_gateway_account: DF.Link | None
payment_order: DF.Link | None
payment_request_type: DF.Literal["Outward", "Inward"]
payment_url: DF.Data | None
print_format: DF.Literal
print_format: DF.Literal[None]
project: DF.Link | None
reference_doctype: DF.Link | None
reference_name: DF.DynamicLink | None
@@ -99,6 +114,12 @@ class PaymentRequest(Document):
frappe.throw(_("To create a Payment Request reference document is required"))
def validate_payment_request_amount(self):
if self.grand_total == 0:
frappe.throw(
_("{0} cannot be zero").format(self.get_label_from_fieldname("grand_total")),
title=_("Invalid Amount"),
)
existing_payment_request_amount = flt(
get_existing_payment_request_amount(self.reference_doctype, self.reference_name)
)
@@ -146,6 +167,28 @@ class PaymentRequest(Document):
).format(self.grand_total, amount)
)
def before_submit(self):
if (
self.currency != self.party_account_currency
and self.party_account_currency == get_company_currency(self.company)
):
# set outstanding amount in party account currency
invoice = frappe.get_value(
self.reference_doctype,
self.reference_name,
["rounded_total", "grand_total", "base_rounded_total", "base_grand_total"],
as_dict=1,
)
grand_total = invoice.get("rounded_total") or invoice.get("grand_total")
base_grand_total = invoice.get("base_rounded_total") or invoice.get("base_grand_total")
self.outstanding_amount = flt(
self.grand_total / grand_total * base_grand_total,
self.precision("outstanding_amount"),
)
else:
self.outstanding_amount = self.grand_total
def on_submit(self):
if self.payment_request_type == "Outward":
self.db_set("status", "Initiated")
@@ -261,12 +304,12 @@ class PaymentRequest(Document):
return controller.get_payment_url(
**{
"amount": flt(self.grand_total, self.precision("grand_total")),
"title": data.company.encode("utf-8"),
"description": self.subject.encode("utf-8"),
"title": data.company,
"description": self.subject,
"reference_doctype": "Payment Request",
"reference_docname": self.name,
"payer_email": self.email_to or frappe.session.user,
"payer_name": frappe.safe_encode(data.customer_name),
"payer_name": data.customer_name,
"order_id": self.name,
"currency": self.currency,
}
@@ -274,7 +317,7 @@ class PaymentRequest(Document):
def set_as_paid(self):
if self.payment_channel == "Phone":
self.db_set("status", "Paid")
self.db_set({"status": "Paid", "outstanding_amount": 0})
else:
payment_entry = self.create_payment_entry()
@@ -295,26 +338,32 @@ class PaymentRequest(Document):
else:
party_account = get_party_account("Customer", ref_doc.get("customer"), ref_doc.company)
party_account_currency = ref_doc.get("party_account_currency") or get_account_currency(party_account)
party_account_currency = (
self.get("party_account_currency")
or ref_doc.get("party_account_currency")
or get_account_currency(party_account)
)
party_amount = bank_amount = self.outstanding_amount
bank_amount = self.grand_total
if party_account_currency == ref_doc.company_currency and party_account_currency != self.currency:
party_amount = ref_doc.get("base_rounded_total") or ref_doc.get("base_grand_total")
else:
party_amount = self.grand_total
exchange_rate = ref_doc.get("conversion_rate")
bank_amount = flt(self.outstanding_amount / exchange_rate, self.precision("grand_total"))
# outstanding amount is already in Part's account currency
payment_entry = get_payment_entry(
self.reference_doctype,
self.reference_name,
party_amount=party_amount,
bank_account=self.payment_account,
bank_amount=bank_amount,
created_from_payment_request=True,
)
payment_entry.update(
{
"mode_of_payment": self.mode_of_payment,
"reference_no": self.name,
"reference_no": self.name, # to prevent validation error
"reference_date": nowdate(),
"remarks": "Payment Entry against {} {} via Payment Request {}".format(
self.reference_doctype, self.reference_name, self.name
@@ -322,6 +371,9 @@ class PaymentRequest(Document):
}
)
# Allocate payment_request for each reference in payment_entry (Payment Term can splits the row)
self._allocate_payment_request_to_pe_references(references=payment_entry.references)
# Update dimensions
payment_entry.update(
{
@@ -330,14 +382,6 @@ class PaymentRequest(Document):
}
)
if party_account_currency == ref_doc.company_currency and party_account_currency != self.currency:
amount = payment_entry.base_paid_amount
else:
amount = self.grand_total
payment_entry.received_amount = amount
payment_entry.get("references")[0].allocated_amount = amount
# Update 'Paid Amount' on Forex transactions
if self.currency != ref_doc.company_currency:
if (
@@ -428,6 +472,62 @@ class PaymentRequest(Document):
return create_stripe_subscription(gateway_controller, data)
def _allocate_payment_request_to_pe_references(self, references):
"""
Allocate the Payment Request to the Payment Entry references based on\n
- Allocated Amount.
- Outstanding Amount of Payment Request.\n
Payment Request is doc itself and references are the rows of Payment Entry.
"""
if len(references) == 1:
references[0].payment_request = self.name
return
precision = references[0].precision("allocated_amount")
outstanding_amount = self.outstanding_amount
# to manage rows
row_number = 1
MOVE_TO_NEXT_ROW = 1
TO_SKIP_NEW_ROW = 2
NEW_ROW_ADDED = False
while row_number <= len(references):
row = references[row_number - 1]
# update the idx to maintain the order
row.idx = row_number
if outstanding_amount == 0:
if not NEW_ROW_ADDED:
break
row_number += MOVE_TO_NEXT_ROW
continue
# allocate the payment request to the row
row.payment_request = self.name
if row.allocated_amount <= outstanding_amount:
outstanding_amount = flt(outstanding_amount - row.allocated_amount, precision)
row_number += MOVE_TO_NEXT_ROW
else:
remaining_allocated_amount = flt(row.allocated_amount - outstanding_amount, precision)
row.allocated_amount = outstanding_amount
outstanding_amount = 0
# create a new row without PR for remaining unallocated amount
new_row = frappe.copy_doc(row)
references.insert(row_number, new_row)
# update new row
new_row.idx = row_number + 1
new_row.payment_request = None
new_row.allocated_amount = remaining_allocated_amount
NEW_ROW_ADDED = True
row_number += TO_SKIP_NEW_ROW
@frappe.whitelist(allow_guest=True)
def make_payment_request(**args):
@@ -435,6 +535,9 @@ def make_payment_request(**args):
args = frappe._dict(args)
if args.dt not in ALLOWED_DOCTYPES_FOR_PAYMENT_REQUEST:
frappe.throw(_("Payment Requests cannot be created against: {0}").format(frappe.bold(args.dt)))
ref_doc = frappe.get_doc(args.dt, args.dn)
gateway_account = get_gateway_details(args) or frappe._dict()
@@ -458,10 +561,38 @@ def make_payment_request(**args):
{"reference_doctype": args.dt, "reference_name": args.dn, "docstatus": 0},
)
existing_payment_request_amount = get_existing_payment_request_amount(args.dt, args.dn)
# fetches existing payment request `grand_total` amount
existing_payment_request_amount = get_existing_payment_request_amount(ref_doc.doctype, ref_doc.name)
existing_paid_amount = get_existing_paid_amount(ref_doc.doctype, ref_doc.name)
def validate_and_calculate_grand_total(grand_total, existing_payment_request_amount):
grand_total -= existing_payment_request_amount
if not grand_total:
frappe.throw(_("Payment Request is already created"))
return grand_total
if existing_payment_request_amount:
grand_total -= existing_payment_request_amount
if args.order_type == "Shopping Cart":
# If Payment Request is in an advanced stage, then create for remaining amount.
if get_existing_payment_request_amount(
ref_doc.doctype, ref_doc.name, ["Initiated", "Partially Paid", "Payment Ordered", "Paid"]
):
grand_total = validate_and_calculate_grand_total(grand_total, existing_payment_request_amount)
else:
# If PR's are processed, cancel all of them.
cancel_old_payment_requests(ref_doc.doctype, ref_doc.name)
else:
grand_total = validate_and_calculate_grand_total(grand_total, existing_payment_request_amount)
if existing_paid_amount:
if ref_doc.party_account_currency == ref_doc.currency:
if ref_doc.conversion_rate:
grand_total -= flt(existing_paid_amount / ref_doc.conversion_rate)
else:
grand_total -= flt(existing_paid_amount)
else:
grand_total -= flt(existing_paid_amount / ref_doc.conversion_rate)
if draft_payment_request:
frappe.db.set_value(
@@ -476,6 +607,13 @@ def make_payment_request(**args):
"Outward" if args.get("dt") in ["Purchase Order", "Purchase Invoice"] else "Inward"
)
party_type = args.get("party_type") or "Customer"
party_account_currency = ref_doc.get("party_account_currency")
if not party_account_currency:
party_account = get_party_account(party_type, ref_doc.get(party_type.lower()), ref_doc.company)
party_account_currency = get_account_currency(party_account)
pr.update(
{
"payment_gateway_account": gateway_account.get("name"),
@@ -484,6 +622,7 @@ def make_payment_request(**args):
"payment_channel": gateway_account.get("payment_channel"),
"payment_request_type": args.get("payment_request_type"),
"currency": ref_doc.currency,
"party_account_currency": party_account_currency,
"grand_total": grand_total,
"mode_of_payment": args.mode_of_payment,
"email_to": args.recipient_id or ref_doc.owner,
@@ -491,9 +630,11 @@ def make_payment_request(**args):
"message": gateway_account.get("message") or get_dummy_message(ref_doc),
"reference_doctype": args.dt,
"reference_name": args.dn,
"party_type": args.get("party_type") or "Customer",
"company": ref_doc.get("company"),
"party_type": party_type,
"party": args.get("party") or ref_doc.get("customer"),
"bank_account": bank_account,
"party_name": args.get("party_name") or ref_doc.get("customer_name"),
}
)
@@ -514,6 +655,8 @@ def make_payment_request(**args):
if frappe.db.get_single_value("Accounts Settings", "create_pr_in_draft_status", cache=True):
pr.insert(ignore_permissions=True)
if args.submit_doc:
if pr.get("__unsaved"):
pr.insert(ignore_permissions=True)
pr.submit()
if args.order_type == "Shopping Cart":
@@ -535,9 +678,11 @@ def get_amount(ref_doc, payment_account=None):
elif dt in ["Sales Invoice", "Purchase Invoice"]:
if not ref_doc.get("is_pos"):
if ref_doc.party_account_currency == ref_doc.currency:
grand_total = flt(ref_doc.grand_total)
grand_total = flt(ref_doc.rounded_total or ref_doc.grand_total)
else:
grand_total = flt(ref_doc.base_grand_total) / ref_doc.conversion_rate
grand_total = flt(
flt(ref_doc.base_rounded_total or ref_doc.base_grand_total) / ref_doc.conversion_rate
)
elif dt == "Sales Invoice":
for pay in ref_doc.payments:
if pay.type == "Phone" and pay.account == payment_account:
@@ -552,31 +697,94 @@ def get_amount(ref_doc, payment_account=None):
grand_total = ref_doc.outstanding_amount
if grand_total > 0:
return grand_total
return flt(grand_total, get_currency_precision())
else:
frappe.throw(_("Payment Entry is already created"))
def get_existing_payment_request_amount(ref_dt, ref_dn):
def get_irequest_status(payment_requests: None | list = None) -> list:
IR = frappe.qb.DocType("Integration Request")
res = []
if payment_requests:
res = (
frappe.qb.from_(IR)
.select(IR.name)
.where(IR.reference_doctype.eq("Payment Request"))
.where(IR.reference_docname.isin(payment_requests))
.where(IR.status.isin(["Authorized", "Completed"]))
.run(as_dict=True)
)
return res
def cancel_old_payment_requests(ref_dt, ref_dn):
PR = frappe.qb.DocType("Payment Request")
if res := (
frappe.qb.from_(PR)
.select(PR.name)
.where(PR.reference_doctype == ref_dt)
.where(PR.reference_name == ref_dn)
.where(PR.docstatus == 1)
.where(PR.status.isin(["Draft", "Requested"]))
.run(as_dict=True)
):
if get_irequest_status([x.name for x in res]):
frappe.throw(_("Another Payment Request is already processed"))
else:
for x in res:
doc = frappe.get_doc("Payment Request", x.name)
doc.flags.ignore_permissions = True
doc.cancel()
if ireqs := get_irequests_of_payment_request(doc.name):
for ireq in ireqs:
frappe.db.set_value("Integration Request", ireq.name, "status", "Cancelled")
def get_existing_payment_request_amount(ref_dt, ref_dn, statuses: list | None = None) -> list:
"""
Get the existing payment request which are unpaid or partially paid for payment channel other than Phone
and get the summation of existing paid payment request for Phone payment channel.
Return the total amount of Payment Requests against a reference document.
"""
existing_payment_request_amount = frappe.db.sql(
"""
select sum(grand_total)
from `tabPayment Request`
where
reference_doctype = %s
and reference_name = %s
and docstatus = 1
and (status != 'Paid'
or (payment_channel = 'Phone'
and status = 'Paid'))
""",
(ref_dt, ref_dn),
PR = frappe.qb.DocType("Payment Request")
query = (
frappe.qb.from_(PR)
.select(Sum(PR.grand_total))
.where(PR.reference_doctype == ref_dt)
.where(PR.reference_name == ref_dn)
.where(PR.docstatus == 1)
)
return flt(existing_payment_request_amount[0][0]) if existing_payment_request_amount else 0
if statuses:
query = query.where(PR.status.isin(statuses))
response = query.run()
return response[0][0] if response[0] else 0
def get_existing_paid_amount(doctype, name):
PL = frappe.qb.DocType("Payment Ledger Entry")
PER = frappe.qb.DocType("Payment Entry Reference")
query = (
frappe.qb.from_(PL)
.left_join(PER)
.on(
(PER.reference_doctype == PL.against_voucher_type) & (PER.reference_name == PL.against_voucher_no)
)
.select(Abs(Sum(PL.amount)).as_("total_paid_amount"))
.where(PL.against_voucher_type.eq(doctype))
.where(PL.against_voucher_no.eq(name))
.where(PL.amount < 0)
.where(PL.delinked == 0)
.where(PER.docstatus == 1)
.where(PER.payment_request.isnull())
)
response = query.run()
return response[0][0] if response[0] else 0
def get_gateway_details(args): # nosemgrep
@@ -623,41 +831,66 @@ def make_payment_entry(docname):
return doc.create_payment_entry(submit=False).as_dict()
def update_payment_req_status(doc, method):
from erpnext.accounts.doctype.payment_entry.payment_entry import get_reference_details
def update_payment_requests_as_per_pe_references(references=None, cancel=False):
"""
Update Payment Request's `Status` and `Outstanding Amount` based on Payment Entry Reference's `Allocated Amount`.
"""
if not references:
return
for ref in doc.references:
payment_request_name = frappe.db.get_value(
"Payment Request",
{
"reference_doctype": ref.reference_doctype,
"reference_name": ref.reference_name,
"docstatus": 1,
},
precision = references[0].precision("allocated_amount")
referenced_payment_requests = frappe.get_all(
"Payment Request",
filters={"name": ["in", {row.payment_request for row in references if row.payment_request}]},
fields=[
"name",
"grand_total",
"outstanding_amount",
"payment_request_type",
],
)
referenced_payment_requests = {pr.name: pr for pr in referenced_payment_requests}
for ref in references:
if not ref.payment_request:
continue
payment_request = referenced_payment_requests[ref.payment_request]
pr_outstanding = payment_request["outstanding_amount"]
# update outstanding amount
new_outstanding_amount = flt(
pr_outstanding + ref.allocated_amount if cancel else pr_outstanding - ref.allocated_amount,
precision,
)
if payment_request_name:
ref_details = get_reference_details(
ref.reference_doctype,
ref.reference_name,
doc.party_account_currency,
doc.party_type,
doc.party,
# to handle same payment request for the multiple allocations
payment_request["outstanding_amount"] = new_outstanding_amount
if not cancel and new_outstanding_amount < 0:
frappe.throw(
msg=_(
"The allocated amount is greater than the outstanding amount of Payment Request {0}"
).format(ref.payment_request),
title=_("Invalid Allocated Amount"),
)
pay_req_doc = frappe.get_doc("Payment Request", payment_request_name)
status = pay_req_doc.status
if status != "Paid" and not ref_details.outstanding_amount:
status = "Paid"
elif status != "Partially Paid" and ref_details.outstanding_amount != ref_details.total_amount:
status = "Partially Paid"
elif ref_details.outstanding_amount == ref_details.total_amount:
if pay_req_doc.payment_request_type == "Outward":
status = "Initiated"
elif pay_req_doc.payment_request_type == "Inward":
status = "Requested"
# update status
if new_outstanding_amount == payment_request["grand_total"]:
status = "Initiated" if payment_request["payment_request_type"] == "Outward" else "Requested"
elif new_outstanding_amount == 0:
status = "Paid"
elif new_outstanding_amount > 0:
status = "Partially Paid"
pay_req_doc.db_set("status", status)
# update database
frappe.db.set_value(
"Payment Request",
ref.payment_request,
{"outstanding_amount": new_outstanding_amount, "status": status},
)
def get_dummy_message(doc):
@@ -741,3 +974,45 @@ def validate_payment(doc, method=None):
doc.reference_docname
)
)
@frappe.whitelist()
def get_open_payment_requests_query(doctype, txt, searchfield, start, page_len, filters):
# permission checks in `get_list()`
filters = frappe._dict(filters)
if not filters.reference_doctype or not filters.reference_name:
return []
if txt:
filters.name = ["like", f"%{txt}%"]
open_payment_requests = frappe.get_list(
"Payment Request",
filters=filters,
fields=["name", "grand_total", "outstanding_amount"],
order_by="transaction_date ASC,creation ASC",
)
return [
(
pr.name,
_("<strong>Grand Total:</strong> {0}").format(pr.grand_total),
_("<strong>Outstanding Amount:</strong> {0}").format(pr.outstanding_amount),
)
for pr in open_payment_requests
]
def get_irequests_of_payment_request(doc: str | None = None) -> list:
res = []
if doc:
res = frappe.db.get_all(
"Integration Request",
{
"reference_doctype": "Payment Request",
"reference_docname": doc,
"status": "Queued",
},
)
return res

View File

@@ -0,0 +1,14 @@
from frappe import _
def get_data():
return {
"fieldname": "payment_request",
"internal_links": {
"Payment Entry": ["references", "payment_request"],
"Payment Order": ["references", "payment_order"],
},
"transactions": [
{"label": _("Payment"), "items": ["Payment Entry", "Payment Order"]},
],
}

View File

@@ -1,19 +1,18 @@
const INDICATORS = {
"Partially Paid": "orange",
Cancelled: "red",
Draft: "red",
Failed: "red",
Initiated: "green",
Paid: "blue",
Requested: "green",
};
frappe.listview_settings["Payment Request"] = {
add_fields: ["status"],
get_indicator: function (doc) {
if (doc.status == "Draft") {
return [__("Draft"), "gray", "status,=,Draft"];
}
if (doc.status == "Requested") {
return [__("Requested"), "green", "status,=,Requested"];
} else if (doc.status == "Initiated") {
return [__("Initiated"), "green", "status,=,Initiated"];
} else if (doc.status == "Partially Paid") {
return [__("Partially Paid"), "orange", "status,=,Partially Paid"];
} else if (doc.status == "Paid") {
return [__("Paid"), "blue", "status,=,Paid"];
} else if (doc.status == "Cancelled") {
return [__("Cancelled"), "red", "status,=,Cancelled"];
}
if (!doc.status || !INDICATORS[doc.status]) return;
return [__(doc.status), INDICATORS[doc.status], `status,=,${doc.status}`];
},
};

View File

@@ -1,11 +1,14 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import re
import unittest
import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.tests.utils import FrappeTestCase, change_settings
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_terms_template
from erpnext.accounts.doctype.payment_request.payment_request import make_payment_request
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
@@ -15,6 +18,7 @@ from erpnext.setup.utils import get_exchange_rate
test_dependencies = ["Currency Exchange", "Journal Entry", "Contact", "Address"]
payment_gateway = {"doctype": "Payment Gateway", "gateway": "_Test Gateway"}
payment_method = [
@@ -278,3 +282,291 @@ class TestPaymentRequest(FrappeTestCase):
self.assertEqual(pe.paid_amount, 800)
self.assertEqual(pe.base_received_amount, 800)
self.assertEqual(pe.received_amount, 10)
def test_multiple_payment_if_partially_paid_for_same_currency(self):
so = make_sales_order(currency="INR", qty=1, rate=1000)
pr = make_payment_request(
dt="Sales Order",
dn=so.name,
mute_email=1,
submit_doc=1,
return_doc=1,
)
self.assertEqual(pr.grand_total, 1000)
self.assertEqual(pr.outstanding_amount, pr.grand_total)
self.assertEqual(pr.party_account_currency, pr.currency) # INR
so.load_from_db()
# to make partial payment
pe = pr.create_payment_entry(submit=False)
pe.paid_amount = 200
pe.references[0].allocated_amount = 200
pe.submit()
self.assertEqual(pe.references[0].payment_request, pr.name)
so.load_from_db()
pr.load_from_db()
self.assertEqual(pr.status, "Partially Paid")
self.assertEqual(pr.outstanding_amount, 800)
self.assertEqual(pr.grand_total, 1000)
# complete payment
pe = pr.create_payment_entry()
self.assertEqual(pe.paid_amount, 800) # paid amount set from pr's outstanding amount
self.assertEqual(pe.references[0].allocated_amount, 800)
self.assertEqual(pe.references[0].outstanding_amount, 800) # for Orders it is not zero
self.assertEqual(pe.references[0].payment_request, pr.name)
so.load_from_db()
pr.load_from_db()
self.assertEqual(pr.status, "Paid")
self.assertEqual(pr.outstanding_amount, 0)
self.assertEqual(pr.grand_total, 1000)
# creating a more payment Request must not allowed
self.assertRaisesRegex(
frappe.exceptions.ValidationError,
re.compile(r"Payment Request is already created"),
make_payment_request,
dt="Sales Order",
dn=so.name,
mute_email=1,
submit_doc=1,
return_doc=1,
)
@change_settings("Accounts Settings", {"allow_multi_currency_invoices_against_single_party_account": 1})
def test_multiple_payment_if_partially_paid_for_multi_currency(self):
pi = make_purchase_invoice(currency="USD", conversion_rate=50, qty=1, rate=100, do_not_save=1)
pi.credit_to = "Creditors - _TC"
pi.submit()
pr = make_payment_request(
dt="Purchase Invoice",
dn=pi.name,
mute_email=1,
submit_doc=1,
return_doc=1,
)
# 100 USD -> 5000 INR
self.assertEqual(pr.grand_total, 100)
self.assertEqual(pr.outstanding_amount, 5000)
self.assertEqual(pr.currency, "USD")
self.assertEqual(pr.party_account_currency, "INR")
self.assertEqual(pr.status, "Initiated")
# to make partial payment
pe = pr.create_payment_entry(submit=False)
pe.paid_amount = 2000
pe.references[0].allocated_amount = 2000
pe.submit()
self.assertEqual(pe.references[0].payment_request, pr.name)
pr.load_from_db()
self.assertEqual(pr.status, "Partially Paid")
self.assertEqual(pr.outstanding_amount, 3000)
self.assertEqual(pr.grand_total, 100)
# complete payment
pe = pr.create_payment_entry()
self.assertEqual(pe.paid_amount, 3000) # paid amount set from pr's outstanding amount
self.assertEqual(pe.references[0].allocated_amount, 3000)
self.assertEqual(pe.references[0].outstanding_amount, 0) # for Invoices it will zero
self.assertEqual(pe.references[0].payment_request, pr.name)
pr.load_from_db()
self.assertEqual(pr.status, "Paid")
self.assertEqual(pr.outstanding_amount, 0)
self.assertEqual(pr.grand_total, 100)
# creating a more payment Request must not allowed
self.assertRaisesRegex(
frappe.exceptions.ValidationError,
re.compile(r"Payment Request is already created"),
make_payment_request,
dt="Purchase Invoice",
dn=pi.name,
mute_email=1,
submit_doc=1,
return_doc=1,
)
def test_single_payment_with_payment_term_for_same_currency(self):
create_payment_terms_template()
po = create_purchase_order(do_not_save=1, currency="INR", qty=1, rate=20000)
po.payment_terms_template = "Test Receivable Template" # 84.746 and 15.254
po.save()
po.submit()
pr = make_payment_request(
dt="Purchase Order",
dn=po.name,
mute_email=1,
submit_doc=1,
return_doc=1,
)
self.assertEqual(pr.grand_total, 20000)
self.assertEqual(pr.outstanding_amount, pr.grand_total)
self.assertEqual(pr.party_account_currency, pr.currency) # INR
self.assertEqual(pr.status, "Initiated")
po.load_from_db()
pe = pr.create_payment_entry()
self.assertEqual(len(pe.references), 2)
self.assertEqual(pe.paid_amount, 20000)
# check 1st payment term
self.assertEqual(pe.references[0].allocated_amount, 16949.2)
self.assertEqual(pe.references[0].payment_request, pr.name)
# check 2nd payment term
self.assertEqual(pe.references[1].allocated_amount, 3050.8)
self.assertEqual(pe.references[1].payment_request, pr.name)
po.load_from_db()
pr.load_from_db()
self.assertEqual(pr.status, "Paid")
self.assertEqual(pr.outstanding_amount, 0)
self.assertEqual(pr.grand_total, 20000)
@change_settings("Accounts Settings", {"allow_multi_currency_invoices_against_single_party_account": 1})
def test_single_payment_with_payment_term_for_multi_currency(self):
create_payment_terms_template()
si = create_sales_invoice(
do_not_save=1, currency="USD", debit_to="Debtors - _TC", qty=1, rate=200, conversion_rate=50
)
si.payment_terms_template = "Test Receivable Template" # 84.746 and 15.254
si.save()
si.submit()
pr = make_payment_request(
dt="Sales Invoice",
dn=si.name,
mute_email=1,
submit_doc=1,
return_doc=1,
)
# 200 USD -> 10000 INR
self.assertEqual(pr.grand_total, 200)
self.assertEqual(pr.outstanding_amount, 10000)
self.assertEqual(pr.currency, "USD")
self.assertEqual(pr.party_account_currency, "INR")
pe = pr.create_payment_entry()
self.assertEqual(len(pe.references), 2)
self.assertEqual(pe.paid_amount, 10000)
# check 1st payment term
# convert it via dollar and conversion_rate
self.assertEqual(pe.references[0].allocated_amount, 8474.5) # multi currency conversion
self.assertEqual(pe.references[0].payment_request, pr.name)
# check 2nd payment term
self.assertEqual(pe.references[1].allocated_amount, 1525.5) # multi currency conversion
self.assertEqual(pe.references[1].payment_request, pr.name)
pr.load_from_db()
self.assertEqual(pr.status, "Paid")
self.assertEqual(pr.outstanding_amount, 0)
self.assertEqual(pr.grand_total, 200)
def test_payment_cancel_process(self):
so = make_sales_order(currency="INR", qty=1, rate=1000)
pr = make_payment_request(
dt="Sales Order",
dn=so.name,
mute_email=1,
submit_doc=1,
return_doc=1,
)
self.assertEqual(pr.grand_total, 1000)
self.assertEqual(pr.outstanding_amount, pr.grand_total)
so.load_from_db()
pe = pr.create_payment_entry(submit=False)
pe.paid_amount = 800
pe.references[0].allocated_amount = 800
pe.submit()
self.assertEqual(pe.references[0].payment_request, pr.name)
so.load_from_db()
pr.load_from_db()
self.assertEqual(pr.status, "Partially Paid")
self.assertEqual(pr.outstanding_amount, 200)
self.assertEqual(pr.grand_total, 1000)
# cancelling PE
pe.cancel()
pr.load_from_db()
self.assertEqual(pr.status, "Requested")
self.assertEqual(pr.outstanding_amount, 1000)
self.assertEqual(pr.grand_total, 1000)
so.load_from_db()
def test_partial_paid_invoice_with_payment_request(self):
si = create_sales_invoice(currency="INR", qty=1, rate=5000)
si.save()
si.submit()
pe = get_payment_entry("Sales Invoice", si.name, bank_account="_Test Bank - _TC")
pe.reference_no = "PAYEE0002"
pe.reference_date = frappe.utils.nowdate()
pe.paid_amount = 2500
pe.references[0].allocated_amount = 2500
pe.save()
pe.submit()
si.load_from_db()
pr = make_payment_request(dt="Sales Invoice", dn=si.name, mute_email=1)
self.assertEqual(pr.grand_total, si.outstanding_amount)
def test_partial_paid_invoice_with_submitted_payment_entry(self):
pi = make_purchase_invoice(currency="INR", qty=1, rate=5000)
pi.save()
pi.submit()
pe = get_payment_entry("Purchase Invoice", pi.name, bank_account="_Test Bank - _TC")
pe.reference_no = "PURINV0001"
pe.reference_date = frappe.utils.nowdate()
pe.paid_amount = 2500
pe.references[0].allocated_amount = 2500
pe.save()
pe.submit()
pe.cancel()
pe = get_payment_entry("Purchase Invoice", pi.name, bank_account="_Test Bank - _TC")
pe.reference_no = "PURINV0002"
pe.reference_date = frappe.utils.nowdate()
pe.paid_amount = 2500
pe.references[0].allocated_amount = 2500
pe.save()
pe.submit()
pi.load_from_db()
pr = make_payment_request(dt="Purchase Invoice", dn=pi.name, mute_email=1)
self.assertEqual(pr.grand_total, pi.outstanding_amount)

View File

@@ -19,6 +19,24 @@ frappe.ui.form.on("Period Closing Voucher", {
});
},
fiscal_year: function (frm) {
if (frm.doc.fiscal_year) {
frappe.call({
method: "erpnext.accounts.doctype.period_closing_voucher.period_closing_voucher.get_period_start_end_date",
args: {
fiscal_year: frm.doc.fiscal_year,
company: frm.doc.company,
},
callback: function (r) {
if (r.message) {
frm.set_value("period_start_date", r.message[0]);
frm.set_value("period_end_date", r.message[1]);
}
},
});
}
},
refresh: function (frm) {
if (frm.doc.docstatus > 0) {
frm.add_custom_button(

View File

@@ -6,39 +6,32 @@
"engine": "InnoDB",
"field_order": [
"transaction_date",
"posting_date",
"fiscal_year",
"year_start_date",
"amended_from",
"company",
"fiscal_year",
"period_start_date",
"period_end_date",
"amended_from",
"column_break1",
"closing_account_head",
"remarks",
"gle_processing_status",
"remarks",
"error_message"
],
"fields": [
{
"default": "Today",
"fieldname": "transaction_date",
"fieldtype": "Date",
"label": "Transaction Date",
"oldfieldname": "transaction_date",
"oldfieldtype": "Date"
},
{
"fieldname": "posting_date",
"fieldtype": "Date",
"label": "Posting Date",
"oldfieldname": "posting_date",
"oldfieldtype": "Date",
"reqd": 1
},
{
"fieldname": "fiscal_year",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Closing Fiscal Year",
"label": "Fiscal Year",
"oldfieldname": "fiscal_year",
"oldfieldtype": "Select",
"options": "Fiscal Year",
@@ -103,16 +96,25 @@
"read_only": 1
},
{
"fieldname": "year_start_date",
"fieldname": "period_end_date",
"fieldtype": "Date",
"label": "Year Start Date"
"label": "Period End Date",
"reqd": 1
},
{
"fieldname": "period_start_date",
"fieldtype": "Date",
"label": "Period Start Date",
"oldfieldname": "posting_date",
"oldfieldtype": "Date",
"reqd": 1
}
],
"icon": "fa fa-file-text",
"idx": 1,
"is_submittable": 1,
"links": [],
"modified": "2023-09-11 20:19:11.810533",
"modified": "2024-09-15 17:22:45.291628",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Period Closing Voucher",
@@ -148,7 +150,7 @@
"write": 1
}
],
"search_fields": "posting_date, fiscal_year",
"search_fields": "fiscal_year, period_start_date, period_end_date",
"sort_field": "modified",
"sort_order": "DESC",
"states": [],

View File

@@ -2,15 +2,20 @@
# License: GNU General Public License v3. See license.txt
import copy
import frappe
from frappe import _
from frappe.query_builder.functions import Sum
from frappe.utils import add_days, flt
from frappe.utils import add_days, flt, formatdate, getdate
from erpnext.accounts.doctype.account_closing_balance.account_closing_balance import (
make_closing_entries,
)
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
)
from erpnext.accounts.utils import get_account_currency, get_fiscal_year, validate_fiscal_year
from erpnext.accounts.utils import get_account_currency, get_fiscal_year
from erpnext.controllers.accounts_controller import AccountsController
@@ -29,38 +34,397 @@ class PeriodClosingVoucher(AccountsController):
error_message: DF.Text | None
fiscal_year: DF.Link
gle_processing_status: DF.Literal["In Progress", "Completed", "Failed"]
posting_date: DF.Date
period_end_date: DF.Date
period_start_date: DF.Date
remarks: DF.SmallText
transaction_date: DF.Date | None
year_start_date: DF.Date | None
# end: auto-generated types
def validate(self):
self.validate_account_head()
self.validate_posting_date()
self.validate_start_and_end_date()
self.check_if_previous_year_closed()
self.block_if_future_closing_voucher_exists()
self.check_closing_account_type()
self.check_closing_account_currency()
def validate_start_and_end_date(self):
self.fy_start_date, self.fy_end_date = frappe.db.get_value(
"Fiscal Year", self.fiscal_year, ["year_start_date", "year_end_date"]
)
prev_closed_period_end_date = get_previous_closed_period_in_current_year(
self.fiscal_year, self.company
)
valid_start_date = (
add_days(prev_closed_period_end_date, 1) if prev_closed_period_end_date else self.fy_start_date
)
if getdate(self.period_start_date) != getdate(valid_start_date):
frappe.throw(_("Period Start Date must be {0}").format(formatdate(valid_start_date)))
if getdate(self.period_start_date) > getdate(self.period_end_date):
frappe.throw(_("Period Start Date cannot be greater than Period End Date"))
if getdate(self.period_end_date) > getdate(self.fy_end_date):
frappe.throw(_("Period End Date cannot be greater than Fiscal Year End Date"))
def check_if_previous_year_closed(self):
last_year_closing = add_days(self.fy_start_date, -1)
previous_fiscal_year = get_fiscal_year(last_year_closing, company=self.company, boolean=True)
if not previous_fiscal_year:
return
previous_fiscal_year_start_date = previous_fiscal_year[0][1]
gle_exists_in_previous_year = frappe.db.exists(
"GL Entry",
{
"posting_date": ("between", [previous_fiscal_year_start_date, last_year_closing]),
"company": self.company,
"is_cancelled": 0,
},
)
if not gle_exists_in_previous_year:
return
previous_fiscal_year_closed = frappe.db.exists(
"Period Closing Voucher",
{
"period_end_date": ("between", [previous_fiscal_year_start_date, last_year_closing]),
"docstatus": 1,
"company": self.company,
},
)
if not previous_fiscal_year_closed:
frappe.throw(_("Previous Year is not closed, please close it first"))
def block_if_future_closing_voucher_exists(self):
future_closing_voucher = self.get_future_closing_voucher()
if future_closing_voucher and future_closing_voucher[0][0]:
action = "cancel" if self.docstatus == 2 else "create"
frappe.throw(
_(
"You cannot {0} this document because another Period Closing Entry {1} exists after {2}"
).format(action, future_closing_voucher[0][0], self.period_end_date)
)
def get_future_closing_voucher(self):
return frappe.db.get_value(
"Period Closing Voucher",
{"period_end_date": (">", self.period_end_date), "docstatus": 1, "company": self.company},
"name",
)
def check_closing_account_type(self):
closing_account_type = frappe.get_cached_value("Account", self.closing_account_head, "root_type")
if closing_account_type not in ["Liability", "Equity"]:
frappe.throw(
_("Closing Account {0} must be of type Liability / Equity").format(self.closing_account_head)
)
def check_closing_account_currency(self):
account_currency = get_account_currency(self.closing_account_head)
company_currency = frappe.get_cached_value("Company", self.company, "default_currency")
if account_currency != company_currency:
frappe.throw(_("Currency of the Closing Account must be {0}").format(company_currency))
def on_submit(self):
self.db_set("gle_processing_status", "In Progress")
get_opening_entries = False
if not frappe.db.exists(
"Period Closing Voucher", {"company": self.company, "docstatus": 1, "name": ("!=", self.name)}
):
get_opening_entries = True
self.make_gl_entries(get_opening_entries=get_opening_entries)
self.make_gl_entries()
def on_cancel(self):
self.validate_future_closing_vouchers()
self.db_set("gle_processing_status", "In Progress")
self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Payment Ledger Entry")
gle_count = frappe.db.count(
"GL Entry",
{"voucher_type": "Period Closing Voucher", "voucher_no": self.name, "is_cancelled": 0},
)
if gle_count > 5000:
self.block_if_future_closing_voucher_exists()
self.db_set("gle_processing_status", "In Progress")
self.cancel_gl_entries()
def make_gl_entries(self):
if self.get_gle_count_in_selected_period() > 5000:
frappe.enqueue(
make_reverse_gl_entries,
process_gl_and_closing_entries,
doc=self,
timeout=1800,
)
frappe.msgprint(
_(
"The GL Entries and closing balances will be processed in the background, it can take a few minutes."
),
alert=True,
)
else:
process_gl_and_closing_entries(self)
def get_gle_count_in_selected_period(self):
return frappe.db.count(
"GL Entry",
{
"posting_date": ["between", [self.period_start_date, self.period_end_date]],
"company": self.company,
"is_cancelled": 0,
},
)
def get_pcv_gl_entries(self):
self.pl_accounts_reverse_gle = []
self.closing_account_gle = []
pl_account_balances = self.get_account_balances_based_on_dimensions(report_type="Profit and Loss")
for dimensions, account_balances in pl_account_balances.items():
for acc, balances in account_balances.items():
balance_in_company_currency = flt(balances.debit) - flt(balances.credit)
if balance_in_company_currency and acc != "balances":
self.pl_accounts_reverse_gle.append(
self.get_gle_for_pl_account(acc, balances, dimensions)
)
# closing liability account
self.closing_account_gle.append(
self.get_gle_for_closing_account(account_balances["balances"], dimensions)
)
return self.pl_accounts_reverse_gle + self.closing_account_gle
def get_gle_for_pl_account(self, acc, balances, dimensions):
balance_in_account_currency = flt(balances.debit_in_account_currency) - flt(
balances.credit_in_account_currency
)
balance_in_company_currency = flt(balances.debit) - flt(balances.credit)
gl_entry = frappe._dict(
{
"company": self.company,
"posting_date": self.period_end_date,
"account": acc,
"account_currency": balances.account_currency,
"debit_in_account_currency": abs(balance_in_account_currency)
if balance_in_account_currency < 0
else 0,
"debit": abs(balance_in_company_currency) if balance_in_company_currency < 0 else 0,
"credit_in_account_currency": abs(balance_in_account_currency)
if balance_in_account_currency > 0
else 0,
"credit": abs(balance_in_company_currency) if balance_in_company_currency > 0 else 0,
"is_period_closing_voucher_entry": 1,
"voucher_type": "Period Closing Voucher",
"voucher_no": self.name,
"fiscal_year": self.fiscal_year,
"remarks": self.remarks,
"is_opening": "No",
}
)
self.update_default_dimensions(gl_entry, dimensions)
return gl_entry
def get_gle_for_closing_account(self, dimension_balance, dimensions):
balance_in_account_currency = flt(dimension_balance.balance_in_account_currency)
balance_in_company_currency = flt(dimension_balance.balance_in_company_currency)
gl_entry = frappe._dict(
{
"company": self.company,
"posting_date": self.period_end_date,
"account": self.closing_account_head,
"account_currency": frappe.db.get_value(
"Account", self.closing_account_head, "account_currency"
),
"debit_in_account_currency": balance_in_account_currency
if balance_in_account_currency > 0
else 0,
"debit": balance_in_company_currency if balance_in_company_currency > 0 else 0,
"credit_in_account_currency": abs(balance_in_account_currency)
if balance_in_account_currency < 0
else 0,
"credit": abs(balance_in_company_currency) if balance_in_company_currency < 0 else 0,
"is_period_closing_voucher_entry": 1,
"voucher_type": "Period Closing Voucher",
"voucher_no": self.name,
"fiscal_year": self.fiscal_year,
"remarks": self.remarks,
"is_opening": "No",
}
)
self.update_default_dimensions(gl_entry, dimensions)
return gl_entry
def update_default_dimensions(self, gl_entry, dimensions):
for i, dimension in enumerate(self.accounting_dimension_fields):
gl_entry[dimension] = dimensions[i]
def get_account_balances_based_on_dimensions(self, report_type):
"""Get balance for dimension-wise pl accounts"""
self.get_accounting_dimension_fields()
acc_bal_dict = frappe._dict()
gl_entries = []
with frappe.db.unbuffered_cursor():
gl_entries = self.get_gl_entries_for_current_period(report_type, as_iterator=True)
for gle in gl_entries:
acc_bal_dict = self.set_account_balance_dict(gle, acc_bal_dict)
if report_type == "Balance Sheet" and self.is_first_period_closing_voucher():
opening_entries = self.get_gl_entries_for_current_period(report_type, only_opening_entries=True)
for gle in opening_entries:
acc_bal_dict = self.set_account_balance_dict(gle, acc_bal_dict)
return acc_bal_dict
def get_accounting_dimension_fields(self):
default_dimensions = ["cost_center", "finance_book", "project"]
self.accounting_dimension_fields = default_dimensions + get_accounting_dimensions()
def get_gl_entries_for_current_period(self, report_type, only_opening_entries=False, as_iterator=False):
date_condition = ""
if only_opening_entries:
date_condition = "is_opening = 'Yes'"
else:
date_condition = f"posting_date BETWEEN '{self.period_start_date}' AND '{self.period_end_date}' and is_opening = 'No'"
# nosemgrep
return frappe.db.sql(
"""
SELECT
name,
posting_date,
account,
account_currency,
debit_in_account_currency,
credit_in_account_currency,
debit,
credit,
{}
FROM `tabGL Entry`
WHERE
{}
AND company = %s
AND voucher_type != 'Period Closing Voucher'
AND EXISTS(SELECT name FROM `tabAccount` WHERE name = account AND report_type = %s)
AND is_cancelled = 0
""".format(
", ".join(self.accounting_dimension_fields),
date_condition,
),
(self.company, report_type),
as_dict=1,
as_iterator=as_iterator,
)
def set_account_balance_dict(self, gle, acc_bal_dict):
key = self.get_key(gle)
acc_bal_dict.setdefault(key, frappe._dict()).setdefault(
gle.account,
frappe._dict(
{
"debit_in_account_currency": 0,
"credit_in_account_currency": 0,
"debit": 0,
"credit": 0,
"account_currency": gle.account_currency,
}
),
)
acc_bal_dict[key][gle.account].debit_in_account_currency += flt(gle.debit_in_account_currency)
acc_bal_dict[key][gle.account].credit_in_account_currency += flt(gle.credit_in_account_currency)
acc_bal_dict[key][gle.account].debit += flt(gle.debit)
acc_bal_dict[key][gle.account].credit += flt(gle.credit)
# dimension-wise total balances
acc_bal_dict[key].setdefault(
"balances",
frappe._dict(
{
"balance_in_account_currency": 0,
"balance_in_company_currency": 0,
}
),
)
balance_in_account_currency = flt(gle.debit_in_account_currency) - flt(gle.credit_in_account_currency)
balance_in_company_currency = flt(gle.debit) - flt(gle.credit)
acc_bal_dict[key]["balances"].balance_in_account_currency += balance_in_account_currency
acc_bal_dict[key]["balances"].balance_in_company_currency += balance_in_company_currency
return acc_bal_dict
def get_key(self, gle):
return tuple([gle.get(dimension) for dimension in self.accounting_dimension_fields])
def get_account_closing_balances(self):
pl_closing_entries = self.get_closing_entries_for_pl_accounts()
bs_closing_entries = self.get_closing_entries_for_balance_sheet_accounts()
closing_entries_for_closing_account = self.get_closing_entries_for_closing_account()
closing_entries = pl_closing_entries + bs_closing_entries + closing_entries_for_closing_account
return closing_entries
def get_closing_entries_for_pl_accounts(self):
closing_entries = copy.deepcopy(self.pl_accounts_reverse_gle)
for d in self.pl_accounts_reverse_gle:
# reverse debit and credit
gle_copy = copy.deepcopy(d)
gle_copy.debit = d.credit
gle_copy.credit = d.debit
gle_copy.debit_in_account_currency = d.credit_in_account_currency
gle_copy.credit_in_account_currency = d.debit_in_account_currency
gle_copy.is_period_closing_voucher_entry = 0
gle_copy.period_closing_voucher = self.name
closing_entries.append(gle_copy)
return closing_entries
def get_closing_entries_for_balance_sheet_accounts(self):
closing_entries = []
balance_sheet_account_balances = self.get_account_balances_based_on_dimensions(
report_type="Balance Sheet"
)
for dimensions, account_balances in balance_sheet_account_balances.items():
for acc, balances in account_balances.items():
balance_in_company_currency = flt(balances.debit) - flt(balances.credit)
if acc != "balances" and balance_in_company_currency:
closing_entries.append(self.get_closing_entry(acc, balances, dimensions))
return closing_entries
def get_closing_entry(self, account, balances, dimensions):
closing_entry = frappe._dict(
{
"company": self.company,
"closing_date": self.period_end_date,
"period_closing_voucher": self.name,
"account": account,
"account_currency": balances.account_currency,
"debit_in_account_currency": flt(balances.debit_in_account_currency),
"debit": flt(balances.debit),
"credit_in_account_currency": flt(balances.credit_in_account_currency),
"credit": flt(balances.credit),
"is_period_closing_voucher_entry": 0,
}
)
self.update_default_dimensions(closing_entry, dimensions)
return closing_entry
def get_closing_entries_for_closing_account(self):
closing_entries = copy.deepcopy(self.closing_account_gle)
for d in closing_entries:
d.period_closing_voucher = self.name
return closing_entries
def is_first_period_closing_voucher(self):
first_pcv = frappe.db.get_value(
"Period Closing Voucher",
{"company": self.company, "docstatus": 1},
"name",
order_by="period_end_date asc",
)
if not first_pcv or first_pcv == self.name:
return True
def cancel_gl_entries(self):
if self.get_gle_count_against_current_pcv() > 5000:
frappe.enqueue(
process_cancellation,
voucher_type="Period Closing Voucher",
voucher_no=self.name,
queue="long",
@@ -71,341 +435,74 @@ class PeriodClosingVoucher(AccountsController):
alert=True,
)
else:
make_reverse_gl_entries(voucher_type="Period Closing Voucher", voucher_no=self.name)
process_cancellation(voucher_type="Period Closing Voucher", voucher_no=self.name)
self.delete_closing_entries()
def validate_future_closing_vouchers(self):
if frappe.db.exists(
"Period Closing Voucher",
{"posting_date": (">", self.posting_date), "docstatus": 1, "company": self.company},
):
frappe.throw(
_(
"You can not cancel this Period Closing Voucher, please cancel the future Period Closing Vouchers first"
)
)
def delete_closing_entries(self):
closing_balance = frappe.qb.DocType("Account Closing Balance")
frappe.qb.from_(closing_balance).delete().where(
closing_balance.period_closing_voucher == self.name
).run()
def validate_account_head(self):
closing_account_type = frappe.get_cached_value("Account", self.closing_account_head, "root_type")
if closing_account_type not in ["Liability", "Equity"]:
frappe.throw(
_("Closing Account {0} must be of type Liability / Equity").format(self.closing_account_head)
)
account_currency = get_account_currency(self.closing_account_head)
company_currency = frappe.get_cached_value("Company", self.company, "default_currency")
if account_currency != company_currency:
frappe.throw(_("Currency of the Closing Account must be {0}").format(company_currency))
def validate_posting_date(self):
validate_fiscal_year(
self.posting_date, self.fiscal_year, self.company, label=_("Posting Date"), doc=self
)
self.year_start_date = get_fiscal_year(self.posting_date, self.fiscal_year, company=self.company)[1]
self.check_if_previous_year_closed()
pcv = frappe.qb.DocType("Period Closing Voucher")
existing_entry = (
frappe.qb.from_(pcv)
.select(pcv.name)
.where(
(pcv.posting_date >= self.posting_date)
& (pcv.fiscal_year == self.fiscal_year)
& (pcv.docstatus == 1)
& (pcv.company == self.company)
)
.run()
)
if existing_entry and existing_entry[0][0]:
frappe.throw(
_("Another Period Closing Entry {0} has been made after {1}").format(
existing_entry[0][0], self.posting_date
)
)
def check_if_previous_year_closed(self):
last_year_closing = add_days(self.year_start_date, -1)
previous_fiscal_year = get_fiscal_year(last_year_closing, company=self.company, boolean=True)
if not previous_fiscal_year:
return
previous_fiscal_year_start_date = previous_fiscal_year[0][1]
if not frappe.db.exists(
def get_gle_count_against_current_pcv(self):
return frappe.db.count(
"GL Entry",
{
"posting_date": ("between", [previous_fiscal_year_start_date, last_year_closing]),
"company": self.company,
"is_cancelled": 0,
},
):
return
if not frappe.db.exists(
"Period Closing Voucher",
{
"posting_date": ("between", [previous_fiscal_year_start_date, last_year_closing]),
"docstatus": 1,
"company": self.company,
},
):
frappe.throw(_("Previous Year is not closed, please close it first"))
def make_gl_entries(self, get_opening_entries=False):
gl_entries = self.get_gl_entries()
closing_entries = self.get_grouped_gl_entries(get_opening_entries=get_opening_entries)
if len(gl_entries + closing_entries) > 3000:
frappe.enqueue(
process_gl_entries,
gl_entries=gl_entries,
voucher_name=self.name,
timeout=3000,
)
frappe.enqueue(
process_closing_entries,
gl_entries=gl_entries,
closing_entries=closing_entries,
voucher_name=self.name,
company=self.company,
closing_date=self.posting_date,
timeout=3000,
)
frappe.msgprint(
_("The GL Entries will be processed in the background, it can take a few minutes."),
alert=True,
)
else:
process_gl_entries(gl_entries, self.name)
process_closing_entries(gl_entries, closing_entries, self.name, self.company, self.posting_date)
def get_grouped_gl_entries(self, get_opening_entries=False):
closing_entries = []
for acc in self.get_balances_based_on_dimensions(
group_by_account=True, for_aggregation=True, get_opening_entries=get_opening_entries
):
closing_entries.append(self.get_closing_entries(acc))
return closing_entries
def get_gl_entries(self):
gl_entries = []
# pl account
for acc in self.get_balances_based_on_dimensions(
group_by_account=True, report_type="Profit and Loss"
):
if flt(acc.bal_in_company_currency):
gl_entries.append(self.get_gle_for_pl_account(acc))
# closing liability account
for acc in self.get_balances_based_on_dimensions(
group_by_account=False, report_type="Profit and Loss"
):
if flt(acc.bal_in_company_currency):
gl_entries.append(self.get_gle_for_closing_account(acc))
return gl_entries
def get_gle_for_pl_account(self, acc):
gl_entry = self.get_gl_dict(
{
"company": self.company,
"closing_date": self.posting_date,
"account": acc.account,
"cost_center": acc.cost_center,
"finance_book": acc.finance_book,
"account_currency": acc.account_currency,
"debit_in_account_currency": abs(flt(acc.bal_in_account_currency))
if flt(acc.bal_in_account_currency) < 0
else 0,
"debit": abs(flt(acc.bal_in_company_currency)) if flt(acc.bal_in_company_currency) < 0 else 0,
"credit_in_account_currency": abs(flt(acc.bal_in_account_currency))
if flt(acc.bal_in_account_currency) > 0
else 0,
"credit": abs(flt(acc.bal_in_company_currency))
if flt(acc.bal_in_company_currency) > 0
else 0,
"is_period_closing_voucher_entry": 1,
},
item=acc,
)
self.update_default_dimensions(gl_entry, acc)
return gl_entry
def get_gle_for_closing_account(self, acc):
gl_entry = self.get_gl_dict(
{
"company": self.company,
"closing_date": self.posting_date,
"account": self.closing_account_head,
"cost_center": acc.cost_center,
"finance_book": acc.finance_book,
"account_currency": acc.account_currency,
"debit_in_account_currency": abs(flt(acc.bal_in_account_currency))
if flt(acc.bal_in_account_currency) > 0
else 0,
"debit": abs(flt(acc.bal_in_company_currency)) if flt(acc.bal_in_company_currency) > 0 else 0,
"credit_in_account_currency": abs(flt(acc.bal_in_account_currency))
if flt(acc.bal_in_account_currency) < 0
else 0,
"credit": abs(flt(acc.bal_in_company_currency))
if flt(acc.bal_in_company_currency) < 0
else 0,
"is_period_closing_voucher_entry": 1,
},
item=acc,
)
self.update_default_dimensions(gl_entry, acc)
return gl_entry
def get_closing_entries(self, acc):
closing_entry = self.get_gl_dict(
{
"company": self.company,
"closing_date": self.posting_date,
"period_closing_voucher": self.name,
"account": acc.account,
"cost_center": acc.cost_center,
"finance_book": acc.finance_book,
"account_currency": acc.account_currency,
"debit_in_account_currency": flt(acc.debit_in_account_currency),
"debit": flt(acc.debit),
"credit_in_account_currency": flt(acc.credit_in_account_currency),
"credit": flt(acc.credit),
},
item=acc,
{"voucher_type": "Period Closing Voucher", "voucher_no": self.name, "is_cancelled": 0},
)
for dimension in self.accounting_dimensions:
closing_entry.update({dimension: acc.get(dimension)})
return closing_entry
def update_default_dimensions(self, gl_entry, acc):
if not self.accounting_dimensions:
self.accounting_dimensions = get_accounting_dimensions()
for dimension in self.accounting_dimensions:
gl_entry.update({dimension: acc.get(dimension)})
def get_balances_based_on_dimensions(
self, group_by_account=False, report_type=None, for_aggregation=False, get_opening_entries=False
):
"""Get balance for dimension-wise pl accounts"""
qb_dimension_fields = ["cost_center", "finance_book", "project"]
self.accounting_dimensions = get_accounting_dimensions()
for dimension in self.accounting_dimensions:
qb_dimension_fields.append(dimension)
if group_by_account:
qb_dimension_fields.append("account")
account_filters = {
"company": self.company,
"is_group": 0,
}
if report_type:
account_filters.update({"report_type": report_type})
accounts = frappe.get_all("Account", filters=account_filters, pluck="name")
gl_entry = frappe.qb.DocType("GL Entry")
query = frappe.qb.from_(gl_entry).select(gl_entry.account, gl_entry.account_currency)
if not for_aggregation:
query = query.select(
(Sum(gl_entry.debit_in_account_currency) - Sum(gl_entry.credit_in_account_currency)).as_(
"bal_in_account_currency"
),
(Sum(gl_entry.debit) - Sum(gl_entry.credit)).as_("bal_in_company_currency"),
)
else:
query = query.select(
(Sum(gl_entry.debit_in_account_currency)).as_("debit_in_account_currency"),
(Sum(gl_entry.credit_in_account_currency)).as_("credit_in_account_currency"),
(Sum(gl_entry.debit)).as_("debit"),
(Sum(gl_entry.credit)).as_("credit"),
)
for dimension in qb_dimension_fields:
query = query.select(gl_entry[dimension])
query = query.where(
(gl_entry.company == self.company)
& (gl_entry.is_cancelled == 0)
& (gl_entry.account.isin(accounts))
)
if get_opening_entries:
query = query.where(
gl_entry.posting_date.between(self.get("year_start_date"), self.posting_date)
| gl_entry.is_opening
== "Yes"
)
else:
query = query.where(
gl_entry.posting_date.between(self.get("year_start_date"), self.posting_date)
& gl_entry.is_opening
== "No"
)
if for_aggregation:
query = query.where(gl_entry.voucher_type != "Period Closing Voucher")
for dimension in qb_dimension_fields:
query = query.groupby(gl_entry[dimension])
return query.run(as_dict=1)
def process_gl_entries(gl_entries, voucher_name):
def process_gl_and_closing_entries(doc):
from erpnext.accounts.general_ledger import make_gl_entries
try:
gl_entries = doc.get_pcv_gl_entries()
if gl_entries:
make_gl_entries(gl_entries, merge_entries=False)
frappe.db.set_value("Period Closing Voucher", voucher_name, "gle_processing_status", "Completed")
closing_entries = doc.get_account_closing_balances()
make_closing_entries(closing_entries, doc.name, doc.company, doc.period_end_date)
frappe.db.set_value(doc.doctype, doc.name, "gle_processing_status", "Completed")
except Exception as e:
frappe.db.rollback()
frappe.log_error(e)
frappe.db.set_value("Period Closing Voucher", voucher_name, "gle_processing_status", "Failed")
frappe.db.set_value(doc.doctype, doc.name, "gle_processing_status", "Failed")
def process_closing_entries(gl_entries, closing_entries, voucher_name, company, closing_date):
from erpnext.accounts.doctype.account_closing_balance.account_closing_balance import (
make_closing_entries,
)
try:
if gl_entries + closing_entries:
make_closing_entries(gl_entries + closing_entries, voucher_name, company, closing_date)
except Exception as e:
frappe.db.rollback()
frappe.log_error(e)
def make_reverse_gl_entries(voucher_type, voucher_no):
def process_cancellation(voucher_type, voucher_no):
from erpnext.accounts.general_ledger import make_reverse_gl_entries
try:
make_reverse_gl_entries(voucher_type=voucher_type, voucher_no=voucher_no)
delete_closing_entries(voucher_no)
frappe.db.set_value("Period Closing Voucher", voucher_no, "gle_processing_status", "Completed")
except Exception as e:
frappe.db.rollback()
frappe.log_error(e)
frappe.db.set_value("Period Closing Voucher", voucher_no, "gle_processing_status", "Failed")
def delete_closing_entries(voucher_no):
closing_balance = frappe.qb.DocType("Account Closing Balance")
frappe.qb.from_(closing_balance).delete().where(
closing_balance.period_closing_voucher == voucher_no
).run()
@frappe.whitelist()
def get_period_start_end_date(fiscal_year, company):
fy_start_date, fy_end_date = frappe.db.get_value(
"Fiscal Year", fiscal_year, ["year_start_date", "year_end_date"]
)
prev_closed_period_end_date = get_previous_closed_period_in_current_year(fiscal_year, company)
period_start_date = (
add_days(prev_closed_period_end_date, 1) if prev_closed_period_end_date else fy_start_date
)
return period_start_date, fy_end_date
def get_previous_closed_period_in_current_year(fiscal_year, company):
prev_closed_period_end_date = frappe.db.get_value(
"Period Closing Voucher",
filters={
"company": company,
"fiscal_year": fiscal_year,
"docstatus": 1,
},
fieldname=["period_end_date"],
order_by="period_end_date desc",
)
return prev_closed_period_end_date

View File

@@ -317,16 +317,18 @@ class TestPeriodClosingVoucher(unittest.TestCase):
repost_doc.posting_date = today()
repost_doc.save()
def make_period_closing_voucher(self, posting_date=None, submit=True):
def make_period_closing_voucher(self, posting_date, submit=True):
surplus_account = create_account()
cost_center = create_cost_center("Test Cost Center 1")
fy = get_fiscal_year(posting_date, company="Test PCV Company")
pcv = frappe.get_doc(
{
"doctype": "Period Closing Voucher",
"transaction_date": posting_date or today(),
"posting_date": posting_date or today(),
"period_start_date": fy[1],
"period_end_date": fy[2],
"company": "Test PCV Company",
"fiscal_year": get_fiscal_year(today(), company="Test PCV Company")[0],
"fiscal_year": fy[0],
"cost_center": cost_center,
"closing_account_head": surplus_account,
"remarks": "test",

View File

@@ -80,8 +80,10 @@ frappe.ui.form.on("POS Closing Entry", {
) {
reset_values(frm);
frappe.run_serially([
() => frappe.dom.freeze(__("Loading Invoices! Please Wait...")),
() => frm.trigger("set_opening_amounts"),
() => frm.trigger("get_pos_invoices"),
() => frappe.dom.unfreeze(),
]);
}
},
@@ -145,7 +147,7 @@ frappe.ui.form.on("POS Closing Entry", {
frm.doc.grand_total += flt(doc.grand_total);
frm.doc.net_total += flt(doc.net_total);
frm.doc.total_quantity += flt(doc.total_qty);
refresh_payments(doc, frm);
refresh_payments(doc, frm, false);
refresh_taxes(doc, frm);
refresh_fields(frm);
set_html_data(frm);
@@ -170,7 +172,7 @@ function set_form_data(data, frm) {
frm.doc.grand_total += flt(d.grand_total);
frm.doc.net_total += flt(d.net_total);
frm.doc.total_quantity += flt(d.total_qty);
refresh_payments(d, frm);
refresh_payments(d, frm, true);
refresh_taxes(d, frm);
});
}
@@ -184,7 +186,7 @@ function add_to_pos_transaction(d, frm) {
});
}
function refresh_payments(d, frm) {
function refresh_payments(d, frm, is_new) {
d.payments.forEach((p) => {
const payment = frm.doc.payment_reconciliation.find(
(pay) => pay.mode_of_payment === p.mode_of_payment
@@ -194,7 +196,7 @@ function refresh_payments(d, frm) {
}
if (payment) {
payment.expected_amount += flt(p.amount);
payment.closing_amount = payment.expected_amount;
if (is_new) payment.closing_amount = payment.expected_amount;
payment.difference = payment.closing_amount - payment.expected_amount;
} else {
frm.add_child("payment_reconciliation", {

View File

@@ -87,19 +87,15 @@ class POSClosingEntry(StatusUpdater):
as_dict=1,
)[0]
if pos_invoice.consolidated_invoice:
invalid_row.setdefault("msg", []).append(
_("POS Invoice is {}").format(frappe.bold("already consolidated"))
)
invalid_row.setdefault("msg", []).append(_("POS Invoice is already consolidated"))
invalid_rows.append(invalid_row)
continue
if pos_invoice.pos_profile != self.pos_profile:
invalid_row.setdefault("msg", []).append(
_("POS Profile doesn't matches {}").format(frappe.bold(self.pos_profile))
_("POS Profile doesn't match {}").format(frappe.bold(self.pos_profile))
)
if pos_invoice.docstatus != 1:
invalid_row.setdefault("msg", []).append(
_("POS Invoice is not {}").format(frappe.bold("submitted"))
)
invalid_row.setdefault("msg", []).append(_("POS Invoice is not submitted"))
if pos_invoice.owner != self.user:
invalid_row.setdefault("msg", []).append(
_("POS Invoice isn't created by user {}").format(frappe.bold(self.owner))

View File

@@ -40,10 +40,24 @@ erpnext.selling.POSInvoiceController = class POSInvoiceController extends erpnex
};
});
this.frm.set_query("item_code", "items", function (doc) {
return {
query: "erpnext.accounts.doctype.pos_invoice.pos_invoice.item_query",
filters: {
has_variants: ["=", 0],
is_sales_item: ["=", 1],
disabled: ["=", 0],
is_fixed_asset: ["=", 0],
pos_profile: ["=", doc.pos_profile],
},
};
});
erpnext.accounts.dimensions.setup_dimension_filters(this.frm, this.frm.doctype);
}
onload_post_render(frm) {
super.onload_post_render();
this.pos_profile(frm);
}
@@ -51,7 +65,7 @@ erpnext.selling.POSInvoiceController = class POSInvoiceController extends erpnex
super.refresh();
if (doc.docstatus == 1 && !doc.is_return) {
this.frm.add_custom_button(__("Return"), this.make_sales_return, __("Create"));
this.frm.add_custom_button(__("Return"), this.make_sales_return.bind(this), __("Create"));
this.frm.page.set_inner_btn_group_as_primary(__("Create"));
}

View File

@@ -48,6 +48,7 @@
"shipping_address",
"company_address",
"company_address_display",
"company_contact_person",
"currency_and_price_list",
"currency",
"conversion_rate",
@@ -1558,12 +1559,19 @@
"fieldname": "update_billed_amount_in_delivery_note",
"fieldtype": "Check",
"label": "Update Billed Amount in Delivery Note"
},
{
"fieldname": "company_contact_person",
"fieldtype": "Link",
"label": "Company Contact Person",
"options": "Contact",
"print_hide": 1
}
],
"icon": "fa fa-file-text",
"is_submittable": 1,
"links": [],
"modified": "2024-03-20 16:00:34.268756",
"modified": "2024-11-26 13:10:50.309570",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Invoice",

View File

@@ -6,6 +6,7 @@ import frappe
from frappe import _, bold
from frappe.query_builder.functions import IfNull, Sum
from frappe.utils import cint, flt, get_link_to_form, getdate, nowdate
from frappe.utils.nestedset import get_descendants_of
from erpnext.accounts.doctype.loyalty_program.loyalty_program import validate_loyalty_points
from erpnext.accounts.doctype.payment_request.payment_request import make_payment_request
@@ -15,6 +16,7 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
update_multi_mode_option,
)
from erpnext.accounts.party import get_due_date, get_party_account
from erpnext.controllers.queries import item_query as _item_query
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
@@ -30,12 +32,8 @@ class POSInvoice(SalesInvoice):
from erpnext.accounts.doctype.payment_schedule.payment_schedule import PaymentSchedule
from erpnext.accounts.doctype.pos_invoice_item.pos_invoice_item import POSInvoiceItem
from erpnext.accounts.doctype.pricing_rule_detail.pricing_rule_detail import PricingRuleDetail
from erpnext.accounts.doctype.sales_invoice_advance.sales_invoice_advance import (
SalesInvoiceAdvance,
)
from erpnext.accounts.doctype.sales_invoice_payment.sales_invoice_payment import (
SalesInvoicePayment,
)
from erpnext.accounts.doctype.sales_invoice_advance.sales_invoice_advance import SalesInvoiceAdvance
from erpnext.accounts.doctype.sales_invoice_payment.sales_invoice_payment import SalesInvoicePayment
from erpnext.accounts.doctype.sales_invoice_timesheet.sales_invoice_timesheet import (
SalesInvoiceTimesheet,
)
@@ -73,6 +71,7 @@ class POSInvoice(SalesInvoice):
company: DF.Link
company_address: DF.Link | None
company_address_display: DF.SmallText | None
company_contact_person: DF.Link | None
consolidated_invoice: DF.Link | None
contact_display: DF.SmallText | None
contact_email: DF.Data | None
@@ -188,7 +187,7 @@ class POSInvoice(SalesInvoice):
def validate(self):
if not cint(self.is_pos):
frappe.throw(
_("POS Invoice should have {} field checked.").format(frappe.bold("Include Payment"))
_("POS Invoice should have the field {0} checked.").format(frappe.bold(_("Include Payment")))
)
# run on validate method of selling controller
@@ -449,7 +448,7 @@ class POSInvoice(SalesInvoice):
if self.is_return and entry.amount > 0:
frappe.throw(_("Row #{0} (Payment Table): Amount must be negative").format(entry.idx))
if self.is_return:
if self.is_return and self.docstatus != 0:
invoice_total = self.rounded_total or self.grand_total
total_amount_in_payments = flt(total_amount_in_payments, self.precision("grand_total"))
if total_amount_in_payments and total_amount_in_payments < invoice_total:
@@ -837,3 +836,30 @@ def add_return_modes(doc, pos_profile):
]:
payment_mode = get_mode_of_payment_info(mode_of_payment, doc.company)
append_payment(payment_mode[0])
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def item_query(doctype, txt, searchfield, start, page_len, filters, as_dict=False):
if pos_profile := filters.get("pos_profile")[1]:
pos_profile = frappe.get_cached_doc("POS Profile", pos_profile)
if item_groups := get_item_group(pos_profile):
filters["item_group"] = ["in", tuple(item_groups)]
del filters["pos_profile"]
else:
filters.pop("pos_profile", None)
return _item_query(doctype, txt, searchfield, start, page_len, filters, as_dict)
def get_item_group(pos_profile):
item_groups = []
if pos_profile.get("item_groups"):
# Get items based on the item groups defined in the POS profile
for row in pos_profile.get("item_groups"):
item_groups.append(row.item_group)
item_groups.extend(get_descendants_of("Item Group", row.item_group))
return list(set(item_groups))

View File

@@ -93,7 +93,7 @@ class TestPOSInvoice(unittest.TestCase):
inv.save()
self.assertEqual(inv.net_total, 4298.25)
self.assertEqual(inv.net_total, 4298.24)
self.assertEqual(inv.grand_total, 4900.00)
def test_tax_calculation_with_multiple_items(self):

View File

@@ -97,16 +97,15 @@ class POSInvoiceMergeLog(Document):
return_against_status = frappe.db.get_value("POS Invoice", return_against, "status")
if return_against_status != "Consolidated":
# if return entry is not getting merged in the current pos closing and if it is not consolidated
bold_unconsolidated = frappe.bold("not Consolidated")
msg = _("Row #{}: Original Invoice {} of return invoice {} is {}.").format(
d.idx, bold_return_against, bold_pos_invoice, bold_unconsolidated
)
msg = _(
"Row #{}: The original Invoice {} of return invoice {} is not consolidated."
).format(d.idx, bold_return_against, bold_pos_invoice)
msg += " "
msg += _(
"Original invoice should be consolidated before or along with the return invoice."
"The original invoice should be consolidated before or along with the return invoice."
)
msg += "<br><br>"
msg += _("You can add original invoice {} manually to proceed.").format(
msg += _("You can add the original invoice {} manually to proceed.").format(
bold_return_against
)
frappe.throw(msg)
@@ -439,7 +438,9 @@ def split_invoices(invoices):
if not item.serial_no and not item.serial_and_batch_bundle:
continue
return_against_is_added = any(d for d in _invoices if d.pos_invoice == pos_invoice.return_against)
return_against_is_added = any(
d for d in _invoices if d and d[0].pos_invoice == pos_invoice.return_against
)
if return_against_is_added:
break

View File

@@ -343,7 +343,7 @@ class TestPOSInvoiceMergeLog(unittest.TestCase):
inv.load_from_db()
consolidated_invoice = frappe.get_doc("Sales Invoice", inv.consolidated_invoice)
self.assertEqual(consolidated_invoice.status, "Return")
self.assertEqual(consolidated_invoice.rounding_adjustment, -0.001)
self.assertEqual(consolidated_invoice.rounding_adjustment, -0.002)
finally:
frappe.set_user("Administrator")

View File

@@ -419,7 +419,8 @@
"depends_on": "eval:doc.rate_or_discount==\"Rate\"",
"fieldname": "rate",
"fieldtype": "Currency",
"label": "Rate"
"label": "Rate",
"options": "currency"
},
{
"default": "0",
@@ -647,7 +648,7 @@
"icon": "fa fa-gift",
"idx": 1,
"links": [],
"modified": "2024-05-17 13:16:34.496704",
"modified": "2024-09-16 18:14:51.314765",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Pricing Rule",
@@ -709,4 +710,4 @@
"sort_order": "DESC",
"states": [],
"title_field": "title"
}
}

View File

@@ -186,7 +186,8 @@ class PricingRule(Document):
if not self.priority:
throw(
_("As the field {0} is enabled, the field {1} is mandatory.").format(
frappe.bold("Apply Discount on Discounted Rate"), frappe.bold("Priority")
frappe.bold(_("Apply Discount on Discounted Rate")),
frappe.bold(_("Priority")),
)
)
@@ -194,7 +195,7 @@ class PricingRule(Document):
throw(
_(
"As the field {0} is enabled, the value of the field {1} should be more than 1."
).format(frappe.bold("Apply Discount on Discounted Rate"), frappe.bold("Priority"))
).format(frappe.bold(_("Apply Discount on Discounted Rate")), frappe.bold(_("Priority")))
)
def validate_applicable_for_selling_or_buying(self):
@@ -445,7 +446,20 @@ def get_pricing_rule_for_item(args, doc=None, for_validate=False):
if isinstance(pricing_rule, str):
pricing_rule = frappe.get_cached_doc("Pricing Rule", pricing_rule)
update_pricing_rule_uom(pricing_rule, args)
pricing_rule.apply_rule_on_other_items = get_pricing_rule_items(pricing_rule) or []
fetch_other_item = True if pricing_rule.apply_rule_on_other else False
pricing_rule.apply_rule_on_other_items = (
get_pricing_rule_items(pricing_rule, other_items=fetch_other_item) or []
)
if pricing_rule.coupon_code_based == 1:
if not args.coupon_code:
return item_details
coupon_code = frappe.db.get_value(
doctype="Coupon Code", filters={"pricing_rule": pricing_rule.name}, fieldname="name"
)
if args.coupon_code != coupon_code:
continue
if pricing_rule.get("suggestion"):
continue
@@ -472,9 +486,6 @@ def get_pricing_rule_for_item(args, doc=None, for_validate=False):
pricing_rule.apply_rule_on_other_items
)
if pricing_rule.coupon_code_based == 1 and args.coupon_code is None:
return item_details
if not pricing_rule.validate_applied_rule:
if pricing_rule.price_or_product_discount == "Price":
apply_price_discount_rule(pricing_rule, item_details, args)

View File

@@ -5,6 +5,7 @@
import unittest
import frappe
from frappe.tests.utils import FrappeTestCase, change_settings
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
@@ -14,7 +15,7 @@ from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.get_item_details import get_item_details
class TestPricingRule(unittest.TestCase):
class TestPricingRule(FrappeTestCase):
def setUp(self):
delete_existing_pricing_rules()
setup_pricing_rule_data()
@@ -1130,6 +1131,51 @@ class TestPricingRule(unittest.TestCase):
self.assertEqual(so.items[1].item_code, "_Test Item")
self.assertEqual(so.items[1].qty, 3)
so = make_sales_order(item_code="_Test Item", qty=5, do_not_submit=1)
so.items[0].qty = 1
del so.items[-1]
so.save()
self.assertEqual(len(so.items), 1)
def test_pricing_rule_for_product_free_item_round_free_qty(self):
frappe.delete_doc_if_exists("Pricing Rule", "_Test Pricing Rule")
test_record = {
"doctype": "Pricing Rule",
"title": "_Test Pricing Rule",
"apply_on": "Item Code",
"currency": "USD",
"items": [
{
"item_code": "_Test Item",
}
],
"selling": 1,
"rate": 0,
"min_qty": 100,
"max_qty": 0,
"price_or_product_discount": "Product",
"same_item": 1,
"free_qty": 10,
"round_free_qty": 1,
"is_recursive": 1,
"recurse_for": 100,
"company": "_Test Company",
}
frappe.get_doc(test_record.copy()).insert()
# With pricing rule
so = make_sales_order(item_code="_Test Item", qty=100)
so.load_from_db()
self.assertEqual(so.items[1].is_free_item, 1)
self.assertEqual(so.items[1].item_code, "_Test Item")
self.assertEqual(so.items[1].qty, 10)
so = make_sales_order(item_code="_Test Item", qty=150)
so.load_from_db()
self.assertEqual(so.items[1].is_free_item, 1)
self.assertEqual(so.items[1].item_code, "_Test Item")
self.assertEqual(so.items[1].qty, 10)
def test_apply_multiple_pricing_rules_for_discount_percentage_and_amount(self):
frappe.delete_doc_if_exists("Pricing Rule", "_Test Pricing Rule 1")
frappe.delete_doc_if_exists("Pricing Rule", "_Test Pricing Rule 2")

View File

@@ -486,7 +486,7 @@ def get_qty_and_rate_for_other_item(doc, pr_doc, pricing_rules, row_item):
continue
stock_qty = row.get("qty") * (row.get("conversion_factor") or 1.0)
amount = stock_qty * (row.get("price_list_rate") or row.get("rate"))
amount = stock_qty * (flt(row.get("price_list_rate")) or flt(row.get("rate")))
pricing_rules = filter_pricing_rules_for_qty_amount(stock_qty, amount, pricing_rules, row)
if pricing_rules and pricing_rules[0]:
@@ -651,11 +651,23 @@ def get_product_discount_rule(pricing_rule, item_details, args=None, doc=None):
qty = pricing_rule.free_qty or 1
if pricing_rule.is_recursive:
transaction_qty = (args.get("qty") if args else doc.total_qty) - pricing_rule.apply_recursion_over
if transaction_qty:
transaction_qty = sum(
[
row.qty
for row in doc.items
if not row.is_free_item
and row.item_code == args.item_code
and row.pricing_rules == args.pricing_rules
]
)
transaction_qty = transaction_qty - pricing_rule.apply_recursion_over
if transaction_qty and transaction_qty > 0:
qty = flt(transaction_qty) * qty / pricing_rule.recurse_for
if pricing_rule.round_free_qty:
qty = math.floor(qty)
qty = (flt(transaction_qty) // pricing_rule.recurse_for) * (pricing_rule.free_qty or 1)
if not qty:
return
free_item_data_args = {
"item_code": free_item,
@@ -725,14 +737,11 @@ def get_pricing_rule_items(pr_doc, other_items=False) -> list:
def validate_coupon_code(coupon_name):
coupon = frappe.get_doc("Coupon Code", coupon_name)
if coupon.valid_from:
if coupon.valid_from > getdate(today()):
frappe.throw(_("Sorry, this coupon code's validity has not started"))
elif coupon.valid_upto:
if coupon.valid_upto < getdate(today()):
frappe.throw(_("Sorry, this coupon code's validity has expired"))
elif coupon.used >= coupon.maximum_use:
if coupon.valid_from and coupon.valid_from > getdate(today()):
frappe.throw(_("Sorry, this coupon code's validity has not started"))
elif coupon.valid_upto and coupon.valid_upto < getdate(today()):
frappe.throw(_("Sorry, this coupon code's validity has expired"))
elif coupon.maximum_use and coupon.used >= coupon.maximum_use:
frappe.throw(_("Sorry, this coupon code is no longer valid"))

View File

@@ -20,6 +20,17 @@ frappe.ui.form.on("Process Payment Reconciliation", {
},
};
});
frm.set_query("default_advance_account", function (doc) {
return {
filters: {
company: doc.company,
is_group: 0,
account_type: doc.party_type == "Customer" ? "Receivable" : "Payable",
root_type: doc.party_type == "Customer" ? "Liability" : "Asset",
},
};
});
frm.set_query("cost_center", function (doc) {
return {
filters: {
@@ -102,6 +113,7 @@ frappe.ui.form.on("Process Payment Reconciliation", {
company(frm) {
frm.set_value("party", "");
frm.set_value("receivable_payable_account", "");
frm.set_value("default_advance_account", "");
},
party_type(frm) {
frm.set_value("party", "");
@@ -109,6 +121,7 @@ frappe.ui.form.on("Process Payment Reconciliation", {
party(frm) {
frm.set_value("receivable_payable_account", "");
frm.set_value("default_advance_account", "");
if (!frm.doc.receivable_payable_account && frm.doc.party_type && frm.doc.party) {
return frappe.call({
method: "erpnext.accounts.party.get_party_account",
@@ -116,10 +129,16 @@ frappe.ui.form.on("Process Payment Reconciliation", {
company: frm.doc.company,
party_type: frm.doc.party_type,
party: frm.doc.party,
include_advance: 1,
},
callback: (r) => {
if (!r.exc && r.message) {
frm.set_value("receivable_payable_account", r.message);
if (typeof r.message === "string") {
frm.set_value("receivable_payable_account", r.message);
} else if (Array.isArray(r.message)) {
frm.set_value("receivable_payable_account", r.message[0]);
frm.set_value("default_advance_account", r.message[1]);
}
}
frm.refresh();
},

View File

@@ -13,6 +13,7 @@
"column_break_io6c",
"party",
"receivable_payable_account",
"default_advance_account",
"filter_section",
"from_invoice_date",
"to_invoice_date",
@@ -141,12 +142,23 @@
{
"fieldname": "section_break_a8yx",
"fieldtype": "Section Break"
},
{
"depends_on": "eval:doc.party",
"description": "Only 'Payment Entries' made against this advance account are supported.",
"documentation_url": "https://docs.erpnext.com/docs/user/manual/en/advance-in-separate-party-account",
"fieldname": "default_advance_account",
"fieldtype": "Link",
"label": "Default Advance Account",
"mandatory_depends_on": "doc.party_type",
"options": "Account",
"reqd": 1
}
],
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2023-08-11 10:56:51.699137",
"modified": "2024-08-27 14:48:56.715320",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Process Payment Reconciliation",
@@ -180,4 +192,4 @@
"sort_order": "DESC",
"states": [],
"title_field": "company"
}
}

View File

@@ -23,6 +23,7 @@ class ProcessPaymentReconciliation(Document):
bank_cash_account: DF.Link | None
company: DF.Link
cost_center: DF.Link | None
default_advance_account: DF.Link
error_log: DF.LongText | None
from_invoice_date: DF.Date | None
from_payment_date: DF.Date | None
@@ -101,6 +102,7 @@ def get_pr_instance(doc: str):
"party_type",
"party",
"receivable_payable_account",
"default_advance_account",
"from_invoice_date",
"to_invoice_date",
"from_payment_date",

View File

@@ -25,6 +25,7 @@
"payment_terms_template",
"sales_partner",
"sales_person",
"show_remarks",
"based_on_payment_terms",
"section_break_3",
"customer_collection",
@@ -390,10 +391,16 @@
"fieldname": "ignore_cr_dr_notes",
"fieldtype": "Check",
"label": "Ignore System Generated Credit / Debit Notes"
},
{
"default": "0",
"fieldname": "show_remarks",
"fieldtype": "Check",
"label": "Show Remarks"
}
],
"links": [],
"modified": "2024-08-13 10:41:18.381165",
"modified": "2024-10-18 17:51:39.108481",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Process Statement Of Accounts",

View File

@@ -70,6 +70,7 @@ class ProcessStatementOfAccounts(Document):
sales_person: DF.Link | None
sender: DF.Link | None
show_net_values_in_party_account: DF.Check
show_remarks: DF.Check
start_date: DF.Date | None
subject: DF.Data | None
terms_and_conditions: DF.Link | None
@@ -187,6 +188,7 @@ def get_common_filters(doc):
"finance_book": doc.finance_book if doc.finance_book else None,
"account": [doc.account] if doc.account else None,
"cost_center": [cc.cost_center_name for cc in doc.cost_center],
"show_remarks": doc.show_remarks,
}
)
@@ -472,6 +474,7 @@ def send_emails(document_name, from_scheduler=False, posting_date=None):
reference_doctype="Process Statement Of Accounts",
reference_name=document_name,
attachments=attachments,
expose_recipients="header",
)
if doc.enable_auto_email and from_scheduler:

View File

@@ -5,6 +5,8 @@
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.query_builder import Criterion
from frappe.query_builder.functions import IfNull
pricing_rule_fields = [
"apply_on",
@@ -162,22 +164,50 @@ class PromotionalScheme(Document):
if self.is_new():
return
transaction_exists = False
docnames = []
invalid_pricing_rule = self.get_invalid_pricing_rules()
# If user has changed applicable for
if self.get_doc_before_save() and self.get_doc_before_save().applicable_for == self.applicable_for:
if not invalid_pricing_rule:
return
docnames = frappe.get_all("Pricing Rule", filters={"promotional_scheme": self.name})
if frappe.db.exists(
"Pricing Rule Detail",
{
"pricing_rule": ["in", invalid_pricing_rule],
"docstatus": ["<", 2],
},
):
raise_for_transaction_exists(self.name)
for docname in docnames:
if frappe.db.exists("Pricing Rule Detail", {"pricing_rule": docname.name, "docstatus": ("<", 2)}):
raise_for_transaction_exists(self.name)
for doc in invalid_pricing_rule:
frappe.delete_doc("Pricing Rule", doc)
if docnames and not transaction_exists:
for docname in docnames:
frappe.delete_doc("Pricing Rule", docname.name)
frappe.msgprint(
_("The following invalid Pricing Rules are deleted:")
+ "<br><br><ul><li>"
+ "</li><li>".join(invalid_pricing_rule)
+ "</li></ul>"
)
def get_invalid_pricing_rules(self):
pr = frappe.qb.DocType("Pricing Rule")
conditions = []
conditions.append(pr.promotional_scheme == self.name)
if self.applicable_for:
applicable_for = frappe.scrub(self.applicable_for)
applicable_for_list = [d.get(applicable_for) for d in self.get(applicable_for)]
conditions.append(
(IfNull(pr.applicable_for, "") != self.applicable_for)
| (
(IfNull(pr.applicable_for, "") == self.applicable_for)
& IfNull(pr[applicable_for], "").notin(applicable_for_list)
)
)
else:
conditions.append(IfNull(pr.applicable_for, "") != "")
return frappe.qb.from_(pr).select(pr.name).where(Criterion.all(conditions)).run(pluck=True)
def on_update(self):
self.validate()

View File

@@ -90,6 +90,31 @@ class TestPromotionalScheme(unittest.TestCase):
price_rules = frappe.get_all("Pricing Rule", filters={"promotional_scheme": ps.name})
self.assertEqual(price_rules, [])
def test_change_applicable_for_values_in_promotional_scheme(self):
ps = make_promotional_scheme(applicable_for="Customer", customer="_Test Customer")
ps.append("customer", {"customer": "_Test Customer 2"})
ps.save()
price_rules = frappe.get_all(
"Pricing Rule", filters={"promotional_scheme": ps.name, "applicable_for": "Customer"}
)
self.assertTrue(len(price_rules), 2)
ps.set("customer", [])
ps.append("customer", {"customer": "_Test Customer 2"})
ps.save()
price_rules = frappe.get_all(
"Pricing Rule",
filters={
"promotional_scheme": ps.name,
"applicable_for": "Customer",
"customer": "_Test Customer",
},
)
self.assertEqual(price_rules, [])
frappe.delete_doc("Promotional Scheme", ps.name)
def test_min_max_amount_configuration(self):
ps = make_promotional_scheme()
ps.price_discount_slabs[0].min_amount = 10

View File

@@ -31,6 +31,13 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
},
};
});
this.frm.set_query("expense_account", "items", function () {
return {
query: "erpnext.controllers.queries.get_expense_account",
filters: { company: doc.company },
};
});
}
onload() {
@@ -335,7 +342,9 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
party_type: "Supplier",
account: this.frm.doc.credit_to,
price_list: this.frm.doc.buying_price_list,
fetch_payment_terms_template: cint(!this.frm.doc.ignore_default_payment_terms_template),
fetch_payment_terms_template: cint(
(this.frm.doc.is_return == 0) & !this.frm.doc.ignore_default_payment_terms_template
),
},
function () {
me.apply_pricing_rule();
@@ -506,13 +515,6 @@ cur_frm.fields_dict["select_print_heading"].get_query = function (doc, cdt, cdn)
};
};
cur_frm.set_query("expense_account", "items", function (doc) {
return {
query: "erpnext.controllers.queries.get_expense_account",
filters: { company: doc.company },
};
});
cur_frm.set_query("wip_composite_asset", "items", function () {
return {
filters: { is_composite_asset: 1, docstatus: 0 },
@@ -561,11 +563,12 @@ frappe.ui.form.on("Purchase Invoice", {
frm.custom_make_buttons = {
"Purchase Invoice": "Return / Debit Note",
"Payment Entry": "Payment",
"Landed Cost Voucher": function () {
frm.trigger("create_landed_cost_voucher");
},
};
if (frm.doc.update_stock) {
frm.custom_make_buttons["Landed Cost Voucher"] = "Landed Cost Voucher";
}
frm.set_query("additional_discount_account", function () {
return {
filters: {
@@ -607,20 +610,6 @@ frappe.ui.form.on("Purchase Invoice", {
});
},
create_landed_cost_voucher: function (frm) {
let lcv = frappe.model.get_new_doc("Landed Cost Voucher");
lcv.company = frm.doc.company;
let lcv_receipt = frappe.model.get_new_doc("Landed Cost Purchase Invoice");
lcv_receipt.receipt_document_type = "Purchase Invoice";
lcv_receipt.receipt_document = frm.doc.name;
lcv_receipt.supplier = frm.doc.supplier;
lcv_receipt.grand_total = frm.doc.grand_total;
lcv.purchase_receipts = [lcv_receipt];
frappe.set_route("Form", lcv.doctype, lcv.name);
},
add_custom_buttons: function (frm) {
if (frm.doc.docstatus == 1 && frm.doc.per_received < 100) {
frm.add_custom_button(
@@ -645,14 +634,40 @@ frappe.ui.form.on("Purchase Invoice", {
__("View")
);
}
if (frm.doc.docstatus === 1 && frm.doc.update_stock) {
frm.add_custom_button(
__("Landed Cost Voucher"),
() => {
frm.events.make_lcv(frm);
},
__("Create")
);
}
},
make_lcv(frm) {
frappe.call({
method: "erpnext.stock.doctype.purchase_receipt.purchase_receipt.make_lcv",
args: {
doctype: frm.doc.doctype,
docname: frm.doc.name,
},
callback: (r) => {
if (r.message) {
var doc = frappe.model.sync(r.message);
frappe.set_route("Form", doc[0].doctype, doc[0].name);
}
},
});
},
onload: function (frm) {
if (frm.doc.__onload && frm.is_new()) {
if (frm.doc.supplier) {
if (frm.doc.__onload && frm.doc.supplier) {
if (frm.is_new()) {
frm.doc.apply_tds = frm.doc.__onload.supplier_tds ? 1 : 0;
}
if (!frm.doc.__onload.enable_apply_tds) {
if (!frm.doc.__onload.supplier_tds) {
frm.set_df_property("apply_tds", "read_only", 1);
}
}

View File

@@ -1134,12 +1134,14 @@
"label": "Payment Terms"
},
{
"depends_on": "eval:(!doc.is_paid && !doc.is_return)",
"fieldname": "payment_terms_template",
"fieldtype": "Link",
"label": "Payment Terms Template",
"options": "Payment Terms Template"
},
{
"depends_on": "eval:(!doc.is_paid && !doc.is_return)",
"fieldname": "payment_schedule",
"fieldtype": "Table",
"label": "Payment Schedule",
@@ -1271,6 +1273,7 @@
"fieldtype": "Select",
"in_standard_filter": 1,
"label": "Status",
"no_copy": 1,
"options": "\nDraft\nReturn\nDebit Note Issued\nSubmitted\nPaid\nPartly Paid\nUnpaid\nOverdue\nCancelled\nInternal Transfer",
"print_hide": 1
},
@@ -1630,7 +1633,7 @@
"idx": 204,
"is_submittable": 1,
"links": [],
"modified": "2024-07-25 19:42:36.931278",
"modified": "2024-10-25 18:13:01.944477",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice",

View File

@@ -285,7 +285,6 @@ class PurchaseInvoice(BuyingController):
self.set_against_expense_account()
self.validate_write_off_account()
self.validate_multiple_billing("Purchase Receipt", "pr_detail", "amount")
self.create_remarks()
self.set_status()
self.validate_purchase_receipt_if_update_stock()
validate_inter_company_party(
@@ -322,10 +321,11 @@ class PurchaseInvoice(BuyingController):
def create_remarks(self):
if not self.remarks:
if self.bill_no and self.bill_date:
self.remarks = _("Against Supplier Invoice {0} dated {1}").format(
self.bill_no, formatdate(self.bill_date)
)
if self.bill_no:
self.remarks = _("Against Supplier Invoice {0}").format(self.bill_no)
if self.bill_date:
self.remarks += " " + _("dated {0}").format(formatdate(self.bill_date))
else:
self.remarks = _("No Remarks")
@@ -346,22 +346,6 @@ class PurchaseInvoice(BuyingController):
self.tax_withholding_category = tds_category
self.set_onload("supplier_tds", tds_category)
# If Linked Purchase Order has TDS applied, enable 'apply_tds' checkbox
if purchase_orders := [x.purchase_order for x in self.items if x.purchase_order]:
po = qb.DocType("Purchase Order")
po_with_tds = (
qb.from_(po)
.select(po.name)
.where(
po.docstatus.eq(1)
& (po.name.isin(purchase_orders))
& (po.apply_tds.eq(1))
& (po.tax_withholding_category.notnull())
)
.run()
)
self.set_onload("enable_apply_tds", True if po_with_tds else False)
super().set_missing_values(for_validate)
def validate_credit_to_acc(self):
@@ -377,16 +361,16 @@ class PurchaseInvoice(BuyingController):
if account.report_type != "Balance Sheet":
frappe.throw(
_(
"Please ensure {} account is a Balance Sheet account. You can change the parent account to a Balance Sheet account or select a different account."
).format(frappe.bold("Credit To")),
"Please ensure that the {0} account is a Balance Sheet account. You can change the parent account to a Balance Sheet account or select a different account."
).format(frappe.bold(_("Credit To"))),
title=_("Invalid Account"),
)
if self.supplier and account.account_type != "Payable":
frappe.throw(
_(
"Please ensure {} account {} is a Payable account. Change the account type to Payable or select a different account."
).format(frappe.bold("Credit To"), frappe.bold(self.credit_to)),
"Please ensure that the {0} account {1} is a Payable account. You can change the account type to Payable or select a different account."
).format(frappe.bold(_("Credit To")), frappe.bold(self.credit_to)),
title=_("Invalid Account"),
)
@@ -634,7 +618,7 @@ class PurchaseInvoice(BuyingController):
"To submit the invoice without purchase order please set {0} as {1} in {2}"
).format(
frappe.bold(_("Purchase Order Required")),
frappe.bold("No"),
frappe.bold(_("No")),
get_link_to_form("Buying Settings", "Buying Settings", "Buying Settings"),
)
throw(msg, title=_("Mandatory Purchase Order"))
@@ -655,7 +639,7 @@ class PurchaseInvoice(BuyingController):
"To submit the invoice without purchase receipt please set {0} as {1} in {2}"
).format(
frappe.bold(_("Purchase Receipt Required")),
frappe.bold("No"),
frappe.bold(_("No")),
get_link_to_form("Buying Settings", "Buying Settings", "Buying Settings"),
)
throw(msg, title=_("Mandatory Purchase Receipt"))
@@ -747,6 +731,9 @@ class PurchaseInvoice(BuyingController):
validate_docs_for_voucher_types(["Purchase Invoice"])
validate_docs_for_deferred_accounting([], [self.name])
def before_submit(self):
self.create_remarks()
def on_submit(self):
super().on_submit()
@@ -876,6 +863,7 @@ class PurchaseInvoice(BuyingController):
self.make_tax_gl_entries(gl_entries)
self.make_internal_transfer_gl_entries(gl_entries)
self.make_gl_entries_for_tax_withholding(gl_entries)
gl_entries = make_regional_gl_entries(gl_entries, self)
@@ -909,32 +897,37 @@ class PurchaseInvoice(BuyingController):
)
if grand_total and not self.is_internal_transfer():
against_voucher = self.name
if self.is_return and self.return_against and not self.update_outstanding_for_self:
against_voucher = self.return_against
self.add_supplier_gl_entry(gl_entries, base_grand_total, grand_total)
# Did not use base_grand_total to book rounding loss gle
gl_entries.append(
self.get_gl_dict(
{
"account": self.credit_to,
"party_type": "Supplier",
"party": self.supplier,
"due_date": self.due_date,
"against": self.against_expense_account,
"credit": base_grand_total,
"credit_in_account_currency": base_grand_total
if self.party_account_currency == self.company_currency
else grand_total,
"against_voucher": against_voucher,
"against_voucher_type": self.doctype,
"project": self.project,
"cost_center": self.cost_center,
},
self.party_account_currency,
item=self,
)
)
def add_supplier_gl_entry(
self, gl_entries, base_grand_total, grand_total, against_account=None, remarks=None, skip_merge=False
):
against_voucher = self.name
if self.is_return and self.return_against and not self.update_outstanding_for_self:
against_voucher = self.return_against
# Did not use base_grand_total to book rounding loss gle
gl = {
"account": self.credit_to,
"party_type": "Supplier",
"party": self.supplier,
"due_date": self.due_date,
"against": against_account or self.against_expense_account,
"credit": base_grand_total,
"credit_in_account_currency": base_grand_total
if self.party_account_currency == self.company_currency
else grand_total,
"against_voucher": against_voucher,
"against_voucher_type": self.doctype,
"project": self.project,
"cost_center": self.cost_center,
"_skip_merge": skip_merge,
}
if remarks:
gl["remarks"] = remarks
gl_entries.append(self.get_gl_dict(gl, self.party_account_currency, item=self))
def make_item_gl_entries(self, gl_entries):
# item gl entries
@@ -1262,7 +1255,11 @@ class PurchaseInvoice(BuyingController):
def update_gross_purchase_amount_for_linked_assets(self, item):
assets = frappe.db.get_all(
"Asset",
filters={"purchase_invoice": self.name, "item_code": item.item_code},
filters={
"purchase_invoice": self.name,
"item_code": item.item_code,
"purchase_invoice_item": ("in", [item.name, ""]),
},
fields=["name", "asset_quantity"],
)
for asset in assets:
@@ -1422,6 +1419,31 @@ class PurchaseInvoice(BuyingController):
)
)
def make_gl_entries_for_tax_withholding(self, gl_entries):
"""
Tax withholding amount is not part of supplier invoice.
Separate supplier GL Entry for correct reporting.
"""
if not self.apply_tds:
return
for row in self.get("taxes"):
if not row.is_tax_withholding_account or not row.tax_amount:
continue
base_tds_amount = row.base_tax_amount_after_discount_amount
tds_amount = row.tax_amount_after_discount_amount
self.add_supplier_gl_entry(gl_entries, base_tds_amount, tds_amount)
self.add_supplier_gl_entry(
gl_entries,
-base_tds_amount,
-tds_amount,
against_account=row.account_head,
remarks=_("TDS Deducted"),
skip_merge=True,
)
def make_payment_gl_entries(self, gl_entries):
# Make Cash GL Entries
if cint(self.is_paid) and self.cash_bank_account and self.paid_amount:
@@ -1515,10 +1537,29 @@ class PurchaseInvoice(BuyingController):
# eg: rounding_adjustment = 0.01 and exchange rate = 0.05 and precision of base_rounding_adjustment is 2
# then base_rounding_adjustment becomes zero and error is thrown in GL Entry
if not self.is_internal_transfer() and self.rounding_adjustment and self.base_rounding_adjustment:
round_off_account, round_off_cost_center = get_round_off_account_and_cost_center(
(
round_off_account,
round_off_cost_center,
round_off_for_opening,
) = get_round_off_account_and_cost_center(
self.company, "Purchase Invoice", self.name, self.use_company_roundoff_cost_center
)
if self.is_opening == "Yes" and self.rounding_adjustment:
if not round_off_for_opening:
frappe.throw(
_(
"Opening Invoice has rounding adjustment of {0}.<br><br> '{1}' account is required to post these values. Please set it in Company: {2}.<br><br> Or, '{3}' can be enabled to not post any rounding adjustment."
).format(
frappe.bold(self.rounding_adjustment),
frappe.bold("Round Off for Opening"),
get_link_to_form("Company", self.company),
frappe.bold("Disable Rounded Total"),
)
)
else:
round_off_account = round_off_for_opening
gl_entries.append(
self.get_gl_dict(
{
@@ -1602,7 +1643,11 @@ class PurchaseInvoice(BuyingController):
for proj, value in projects.items():
res = frappe.qb.from_(pj).select(pj.total_purchase_cost).where(pj.name == proj).for_update().run()
current_purchase_cost = res and res[0][0] or 0
frappe.db.set_value("Project", proj, "total_purchase_cost", current_purchase_cost + value)
# frappe.db.set_value("Project", proj, "total_purchase_cost", current_purchase_cost + value)
project_doc = frappe.get_doc("Project", proj)
project_doc.total_purchase_cost = current_purchase_cost + value
project_doc.calculate_gross_margin()
project_doc.db_update()
def validate_supplier_invoice(self):
if self.bill_date:

View File

@@ -1544,6 +1544,61 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
payment_entry.load_from_db()
self.assertEqual(payment_entry.taxes[0].allocated_amount, 0)
def test_purchase_gl_with_tax_withholding_tax(self):
company = "_Test Company"
tds_account_args = {
"doctype": "Account",
"account_name": "TDS Payable",
"account_type": "Tax",
"parent_account": frappe.db.get_value(
"Account", {"account_name": "Duties and Taxes", "company": company}
),
"company": company,
}
tds_account = create_account(**tds_account_args)
tax_withholding_category = "Test TDS - 194 - Dividends - Individual"
# Update tax withholding category with current fiscal year and rate details
create_tax_witholding_category(tax_withholding_category, company, tds_account)
# create a new supplier to test
supplier = create_supplier(
supplier_name="_Test TDS Advance Supplier",
tax_withholding_category=tax_withholding_category,
)
pi = make_purchase_invoice(
supplier=supplier.name,
rate=3000,
qty=1,
item="_Test Non Stock Item",
do_not_submit=1,
)
pi.apply_tds = 1
pi.tax_withholding_category = tax_withholding_category
pi.save()
pi.submit()
self.assertEqual(pi.taxes[0].tax_amount, 300)
self.assertEqual(pi.taxes[0].account_head, tds_account)
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_no": pi.name, "voucher_type": "Purchase Invoice", "account": "Creditors - _TC"},
fields=["account", "against", "debit", "credit"],
)
for gle in gl_entries:
if gle.debit:
# GL Entry with TDS Amount
self.assertEqual(gle.against, tds_account)
self.assertEqual(gle.debit, 300)
else:
# GL Entry with Purchase Invoice Amount
self.assertEqual(gle.credit, 3000)
def test_provisional_accounting_entry(self):
setup_provisional_accounting()
@@ -1680,6 +1735,30 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
frappe.db.set_single_value("Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", 1)
# Cost of Item is zero in Purchase Receipt
pr = make_purchase_receipt(qty=1, rate=0)
stock_value_difference = frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_type": "Purchase Receipt", "voucher_no": pr.name},
"stock_value_difference",
)
self.assertEqual(stock_value_difference, 0)
pi = create_purchase_invoice_from_receipt(pr.name)
for row in pi.items:
row.rate = 150
pi.save()
pi.submit()
stock_value_difference = frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_type": "Purchase Receipt", "voucher_no": pr.name},
"stock_value_difference",
)
self.assertEqual(stock_value_difference, 150)
# Increase the cost of the item
pr = make_purchase_receipt(qty=1, rate=100)
@@ -2236,6 +2315,139 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
self.assertEqual(pi_expected_values[i][1], gle.debit)
self.assertEqual(pi_expected_values[i][2], gle.credit)
def test_adjust_incoming_rate_from_pi_with_multi_currency(self):
from erpnext.stock.doctype.landed_cost_voucher.test_landed_cost_voucher import (
make_landed_cost_voucher,
)
frappe.db.set_single_value("Buying Settings", "maintain_same_rate", 0)
frappe.db.set_single_value("Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", 1)
# Increase the cost of the item
pr = make_purchase_receipt(
qty=10, rate=1, currency="USD", do_not_save=1, supplier="_Test Supplier USD"
)
pr.conversion_rate = 6300
pr.plc_conversion_rate = 1
pr.save()
pr.submit()
self.assertEqual(pr.conversion_rate, 6300)
self.assertEqual(pr.plc_conversion_rate, 1)
self.assertEqual(pr.base_grand_total, 6300 * 10)
stock_value_difference = frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_type": "Purchase Receipt", "voucher_no": pr.name},
"stock_value_difference",
)
self.assertEqual(stock_value_difference, 6300 * 10)
make_landed_cost_voucher(
company=pr.company,
receipt_document_type="Purchase Receipt",
receipt_document=pr.name,
charges=3000,
distribute_charges_based_on="Qty",
)
pi = create_purchase_invoice_from_receipt(pr.name)
for row in pi.items:
row.rate = 1.1
pi.save()
pi.submit()
stock_value_difference = frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_type": "Purchase Receipt", "voucher_no": pr.name},
"stock_value_difference",
)
self.assertEqual(stock_value_difference, 7230 * 10)
frappe.db.set_single_value("Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", 0)
frappe.db.set_single_value("Buying Settings", "maintain_same_rate", 1)
def test_last_purchase_rate(self):
item = create_item("_Test Item For Last Purchase Rate from PI", is_stock_item=1)
pi1 = make_purchase_invoice(item_code=item.item_code, qty=10, rate=100)
item.reload()
self.assertEqual(item.last_purchase_rate, 100)
pi2 = make_purchase_invoice(item_code=item.item_code, qty=10, rate=200)
item.reload()
self.assertEqual(item.last_purchase_rate, 200)
pi2.cancel()
item.reload()
self.assertEqual(item.last_purchase_rate, 100)
pi1.cancel()
item.reload()
self.assertEqual(item.last_purchase_rate, 0)
def test_opening_invoice_rounding_adjustment_validation(self):
pi = make_purchase_invoice(do_not_save=1)
pi.items[0].rate = 99.98
pi.items[0].qty = 1
pi.items[0].expense_account = "Temporary Opening - _TC"
pi.is_opening = "Yes"
pi.save()
self.assertRaises(frappe.ValidationError, pi.submit)
def _create_opening_roundoff_account(self, company_name):
liability_root = frappe.db.get_all(
"Account",
filters={"company": company_name, "root_type": "Liability", "disabled": 0},
order_by="lft",
limit=1,
)[0]
# setup round off account
if acc := frappe.db.exists(
"Account",
{
"account_name": "Round Off for Opening",
"account_type": "Round Off for Opening",
"company": company_name,
},
):
frappe.db.set_value("Company", company_name, "round_off_for_opening", acc)
else:
acc = frappe.new_doc("Account")
acc.company = company_name
acc.parent_account = liability_root.name
acc.account_name = "Round Off for Opening"
acc.account_type = "Round Off for Opening"
acc.save()
frappe.db.set_value("Company", company_name, "round_off_for_opening", acc.name)
def test_ledger_entries_of_opening_invoice_with_rounding_adjustment(self):
pi = make_purchase_invoice(do_not_save=1)
pi.items[0].rate = 99.98
pi.items[0].qty = 1
pi.items[0].expense_account = "Temporary Opening - _TC"
pi.is_opening = "Yes"
pi.save()
self._create_opening_roundoff_account(pi.company)
pi.submit()
actual = frappe.db.get_all(
"GL Entry",
filters={"voucher_no": pi.name, "is_opening": "Yes", "is_cancelled": False},
fields=["account", "debit", "credit", "is_opening"],
order_by="account,debit",
)
expected = [
{"account": "Creditors - _TC", "debit": 0.0, "credit": 100.0, "is_opening": "Yes"},
{"account": "Round Off for Opening - _TC", "debit": 0.02, "credit": 0.0, "is_opening": "Yes"},
{"account": "Temporary Opening - _TC", "debit": 99.98, "credit": 0.0, "is_opening": "Yes"},
]
self.assertEqual(len(actual), 3)
self.assertEqual(expected, actual)
def set_advance_flag(company, flag, default_account):
frappe.db.set_value(

View File

@@ -505,7 +505,8 @@
"fieldtype": "Link",
"label": "Project",
"options": "Project",
"print_hide": 1
"print_hide": 1,
"search_index": 1
},
{
"allow_on_submit": 1,
@@ -974,7 +975,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2024-07-19 12:12:42.449298",
"modified": "2024-10-28 15:06:19.246141",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice Item",

View File

@@ -45,9 +45,9 @@ class RepostAccountingLedger(Document):
latest_pcv = (
frappe.db.get_all(
"Period Closing Voucher",
filters={"company": self.company},
order_by="posting_date desc",
pluck="posting_date",
filters={"company": self.company, "docstatus": 1},
order_by="period_end_date desc",
pluck="period_end_date",
limit=1,
)
or None

View File

@@ -129,13 +129,15 @@ class TestRepostAccountingLedger(AccountsTestMixin, FrappeTestCase):
cost_center=self.cost_center,
rate=100,
)
fy = get_fiscal_year(today(), company=self.company)
pcv = frappe.get_doc(
{
"doctype": "Period Closing Voucher",
"transaction_date": today(),
"posting_date": today(),
"period_start_date": fy[1],
"period_end_date": today(),
"company": self.company,
"fiscal_year": get_fiscal_year(today(), company=self.company)[0],
"fiscal_year": fy[0],
"cost_center": self.cost_center,
"closing_account_head": self.retained_earnings,
"remarks": "test",

View File

@@ -339,6 +339,9 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
account: this.frm.doc.debit_to,
price_list: this.frm.doc.selling_price_list,
pos_profile: pos_profile,
fetch_payment_terms_template: cint(
(this.frm.doc.is_return == 0) & !this.frm.doc.ignore_default_payment_terms_template
),
},
function () {
me.apply_pricing_rule();
@@ -738,20 +741,6 @@ frappe.ui.form.on("Sales Invoice", {
};
};
frm.set_query("company_address", function (doc) {
if (!doc.company) {
frappe.throw(__("Please set Company"));
}
return {
query: "frappe.contacts.doctype.address.address.address_query",
filters: {
link_doctype: "Company",
link_name: doc.company,
},
};
});
frm.set_query("pos_profile", function (doc) {
if (!doc.company) {
frappe.throw(__("Please set Company"));

View File

@@ -159,8 +159,9 @@
"dispatch_address",
"company_address_section",
"company_address",
"company_addr_col_break",
"company_address_display",
"company_addr_col_break",
"company_contact_person",
"terms_tab",
"payment_schedule_section",
"ignore_default_payment_terms_template",
@@ -2166,6 +2167,13 @@
"label": "Update Outstanding for Self",
"no_copy": 1,
"print_hide": 1
},
{
"fieldname": "company_contact_person",
"fieldtype": "Link",
"label": "Company Contact Person",
"options": "Contact",
"print_hide": 1
}
],
"icon": "fa fa-file-text",
@@ -2178,7 +2186,7 @@
"link_fieldname": "consolidated_invoice"
}
],
"modified": "2024-07-18 15:30:39.428519",
"modified": "2024-11-26 12:34:09.110690",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice",
@@ -2233,4 +2241,4 @@
"title_field": "title",
"track_changes": 1,
"track_seen": 1
}
}

View File

@@ -96,6 +96,7 @@ class SalesInvoice(SellingController):
company: DF.Link
company_address: DF.Link | None
company_address_display: DF.SmallText | None
company_contact_person: DF.Link | None
company_tax_id: DF.Data | None
contact_display: DF.SmallText | None
contact_email: DF.Data | None
@@ -278,7 +279,6 @@ class SalesInvoice(SellingController):
self.check_sales_order_on_hold_or_close("sales_order")
self.validate_debit_to_acc()
self.clear_unallocated_advances("Sales Invoice Advance", "advances")
self.add_remarks()
self.validate_fixed_asset()
self.set_income_account_for_fixed_assets()
self.validate_item_cost_centers()
@@ -298,8 +298,11 @@ class SalesInvoice(SellingController):
self.update_current_stock()
self.validate_delivery_note()
is_deferred_invoice = any(d.get("enable_deferred_revenue") for d in self.get("items"))
# validate service stop date to lie in between start and end date
validate_service_stop_date(self)
if is_deferred_invoice:
validate_service_stop_date(self)
if not self.is_opening:
self.is_opening = "No"
@@ -341,6 +344,7 @@ class SalesInvoice(SellingController):
):
validate_loyalty_points(self, self.loyalty_points)
self.allow_write_off_only_on_pos()
self.reset_default_field_value("set_warehouse", "items", "warehouse")
def validate_accounts(self):
@@ -422,6 +426,9 @@ class SalesInvoice(SellingController):
self.set_account_for_mode_of_payment()
self.set_paid_amount()
def before_submit(self):
self.add_remarks()
def on_submit(self):
self.validate_pos_paid_amount()
@@ -514,7 +521,7 @@ class SalesInvoice(SellingController):
)
if pos_closing_entry and pos_closing_entry[0]:
msg = _("To cancel a {} you need to cancel the POS Closing Entry {}.").format(
frappe.bold("Consolidated Sales Invoice"),
frappe.bold(_("Consolidated Sales Invoice")),
get_link_to_form("POS Closing Entry", pos_closing_entry[0]),
)
frappe.throw(msg, title=_("Not Allowed"))
@@ -858,7 +865,7 @@ class SalesInvoice(SellingController):
if account.report_type != "Balance Sheet":
msg = (
_("Please ensure {} account is a Balance Sheet account.").format(frappe.bold("Debit To"))
_("Please ensure {} account is a Balance Sheet account.").format(frappe.bold(_("Debit To")))
+ " "
)
msg += _(
@@ -869,7 +876,7 @@ class SalesInvoice(SellingController):
if self.customer and account.account_type != "Receivable":
msg = (
_("Please ensure {} account {} is a Receivable account.").format(
frappe.bold("Debit To"), frappe.bold(self.debit_to)
frappe.bold(_("Debit To")), frappe.bold(self.debit_to)
)
+ " "
)
@@ -946,10 +953,11 @@ class SalesInvoice(SellingController):
def add_remarks(self):
if not self.remarks:
if self.po_no and self.po_date:
self.remarks = _("Against Customer Order {0} dated {1}").format(
self.po_no, formatdate(self.po_date)
)
if self.po_no:
self.remarks = _("Against Customer Order {0}").format(self.po_no)
if self.po_date:
self.remarks += " " + _("dated {0}").format(formatdate(self.po_date))
else:
self.remarks = _("No Remarks")
@@ -1018,6 +1026,10 @@ class SalesInvoice(SellingController):
raise_exception=1,
)
def allow_write_off_only_on_pos(self):
if not self.is_pos and self.write_off_account:
self.write_off_account = None
def validate_write_off_account(self):
if flt(self.write_off_amount) and not self.write_off_account:
self.write_off_account = frappe.get_cached_value("Company", self.company, "write_off_account")
@@ -1351,14 +1363,15 @@ class SalesInvoice(SellingController):
else:
if asset.calculate_depreciation:
notes = _(
"This schedule was created when Asset {0} was sold through Sales Invoice {1}."
).format(
get_link_to_form(asset.doctype, asset.name),
get_link_to_form(self.doctype, self.get("name")),
)
depreciate_asset(asset, self.posting_date, notes)
asset.reload()
if not asset.status == "Fully Depreciated":
notes = _(
"This schedule was created when Asset {0} was sold through Sales Invoice {1}."
).format(
get_link_to_form(asset.doctype, asset.name),
get_link_to_form(self.doctype, self.get("name")),
)
depreciate_asset(asset, self.posting_date, notes)
asset.reload()
fixed_asset_gl_entries = get_gl_entries_on_asset_disposal(
asset,
@@ -1621,10 +1634,29 @@ class SalesInvoice(SellingController):
and self.base_rounding_adjustment
and not self.is_internal_transfer()
):
round_off_account, round_off_cost_center = get_round_off_account_and_cost_center(
(
round_off_account,
round_off_cost_center,
round_off_for_opening,
) = get_round_off_account_and_cost_center(
self.company, "Sales Invoice", self.name, self.use_company_roundoff_cost_center
)
if self.is_opening == "Yes" and self.rounding_adjustment:
if not round_off_for_opening:
frappe.throw(
_(
"Opening Invoice has rounding adjustment of {0}.<br><br> '{1}' account is required to post these values. Please set it in Company: {2}.<br><br> Or, '{3}' can be enabled to not post any rounding adjustment."
).format(
frappe.bold(self.rounding_adjustment),
frappe.bold("Round Off for Opening"),
get_link_to_form("Company", self.company),
frappe.bold("Disable Rounded Total"),
)
)
else:
round_off_account = round_off_for_opening
gl_entries.append(
self.get_gl_dict(
{
@@ -1722,9 +1754,14 @@ class SalesInvoice(SellingController):
)
def update_project(self):
if self.project:
project = frappe.get_doc("Project", self.project)
unique_projects = list(set([d.project for d in self.get("items") if d.project]))
if self.project and self.project not in unique_projects:
unique_projects.append(self.project)
for p in unique_projects:
project = frappe.get_doc("Project", p)
project.update_billed_amount()
project.calculate_gross_margin()
project.db_update()
def verify_payment_amount_is_positive(self):
@@ -2115,7 +2152,7 @@ def make_delivery_note(source_name, target_doc=None):
"postprocess": update_item,
"condition": lambda doc: doc.delivered_by_supplier != 1,
},
"Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "add_if_empty": True},
"Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "reset_value": True},
"Sales Team": {
"doctype": "Sales Team",
"field_map": {"incentives": "incentives"},

View File

@@ -15,7 +15,7 @@ frappe.listview_settings["Sales Invoice"] = {
],
get_indicator: function (doc) {
const status_colors = {
Draft: "grey",
Draft: "red",
Unpaid: "orange",
Paid: "green",
Return: "gray",

View File

@@ -8,7 +8,7 @@ import frappe
from frappe import qb
from frappe.model.dynamic_links import get_dynamic_link_map
from frappe.tests.utils import FrappeTestCase, change_settings
from frappe.utils import add_days, flt, getdate, nowdate, today
from frappe.utils import add_days, flt, format_date, getdate, nowdate, today
import erpnext
from erpnext.accounts.doctype.account.test_account import create_account, get_inventory_account
@@ -314,7 +314,8 @@ class TestSalesInvoice(FrappeTestCase):
si.insert()
# with inclusive tax
self.assertEqual(si.items[0].net_amount, 3947.368421052631)
self.assertEqual(si.items[0].net_amount, 3947.37)
self.assertEqual(si.net_total, si.base_net_total)
self.assertEqual(si.net_total, 3947.37)
self.assertEqual(si.grand_total, 5000)
@@ -658,7 +659,7 @@ class TestSalesInvoice(FrappeTestCase):
62.5,
625.0,
50,
499.97600115194473,
499.98,
],
"_Test Item Home Desktop 200": [
190.66,
@@ -669,7 +670,7 @@ class TestSalesInvoice(FrappeTestCase):
190.66,
953.3,
150,
749.9968530500239,
750,
],
}
@@ -682,20 +683,21 @@ class TestSalesInvoice(FrappeTestCase):
self.assertEqual(d.get(k), expected_values[d.item_code][i])
# check net total
self.assertEqual(si.net_total, 1249.97)
self.assertEqual(si.base_net_total, si.net_total)
self.assertEqual(si.net_total, 1249.98)
self.assertEqual(si.total, 1578.3)
# check tax calculation
expected_values = {
"keys": ["tax_amount", "total"],
"_Test Account Excise Duty - _TC": [140, 1389.97],
"_Test Account Education Cess - _TC": [2.8, 1392.77],
"_Test Account S&H Education Cess - _TC": [1.4, 1394.17],
"_Test Account CST - _TC": [27.88, 1422.05],
"_Test Account VAT - _TC": [156.25, 1578.30],
"_Test Account Customs Duty - _TC": [125, 1703.30],
"_Test Account Shipping Charges - _TC": [100, 1803.30],
"_Test Account Discount - _TC": [-180.33, 1622.97],
"_Test Account Excise Duty - _TC": [140, 1389.98],
"_Test Account Education Cess - _TC": [2.8, 1392.78],
"_Test Account S&H Education Cess - _TC": [1.4, 1394.18],
"_Test Account CST - _TC": [27.88, 1422.06],
"_Test Account VAT - _TC": [156.25, 1578.31],
"_Test Account Customs Duty - _TC": [125, 1703.31],
"_Test Account Shipping Charges - _TC": [100, 1803.31],
"_Test Account Discount - _TC": [-180.33, 1622.98],
}
for d in si.get("taxes"):
@@ -731,7 +733,7 @@ class TestSalesInvoice(FrappeTestCase):
"base_rate": 2500,
"base_amount": 25000,
"net_rate": 40,
"net_amount": 399.9808009215558,
"net_amount": 399.98,
"base_net_rate": 2000,
"base_net_amount": 19999,
},
@@ -745,7 +747,7 @@ class TestSalesInvoice(FrappeTestCase):
"base_rate": 7500,
"base_amount": 37500,
"net_rate": 118.01,
"net_amount": 590.0531205155963,
"net_amount": 590.05,
"base_net_rate": 5900.5,
"base_net_amount": 29502.5,
},
@@ -783,8 +785,13 @@ class TestSalesInvoice(FrappeTestCase):
self.assertEqual(si.base_grand_total, 60795)
self.assertEqual(si.grand_total, 1215.90)
self.assertEqual(si.rounding_adjustment, 0.01)
self.assertEqual(si.base_rounding_adjustment, 0.50)
# no rounding adjustment as the Smallest Currency Fraction Value of USD is 0.01
if frappe.db.get_value("Currency", "USD", "smallest_currency_fraction_value") < 0.01:
self.assertEqual(si.rounding_adjustment, 0.10)
self.assertEqual(si.base_rounding_adjustment, 5.0)
else:
self.assertEqual(si.rounding_adjustment, 0.0)
self.assertEqual(si.base_rounding_adjustment, 0.0)
def test_outstanding(self):
w = self.make()
@@ -1995,7 +2002,7 @@ class TestSalesInvoice(FrappeTestCase):
# Check if SO is unlinked/replaced by SI in PE & if SO advance paid is 0
self.assertEqual(pe.references[0].reference_name, si.name)
self.assertEqual(sales_order.advance_paid, 0.0)
self.assertEqual(sales_order.advance_paid, 300.0)
# check outstanding after advance allocation
self.assertEqual(
@@ -2172,7 +2179,7 @@ class TestSalesInvoice(FrappeTestCase):
def test_rounding_adjustment_2(self):
si = create_sales_invoice(rate=400, do_not_save=True)
for rate in [400, 600, 100]:
for rate in [400.25, 600.30, 100.65]:
si.append(
"items",
{
@@ -2198,18 +2205,19 @@ class TestSalesInvoice(FrappeTestCase):
)
si.save()
si.submit()
self.assertEqual(si.net_total, 1271.19)
self.assertEqual(si.grand_total, 1500)
self.assertEqual(si.total_taxes_and_charges, 228.82)
self.assertEqual(si.rounding_adjustment, -0.01)
self.assertEqual(si.net_total, si.base_net_total)
self.assertEqual(si.net_total, 1272.20)
self.assertEqual(si.grand_total, 1501.20)
self.assertEqual(si.total_taxes_and_charges, 229)
self.assertEqual(si.rounding_adjustment, -0.20)
round_off_account = frappe.get_cached_value("Company", "_Test Company", "round_off_account")
expected_values = {
"_Test Account Service Tax - _TC": [0.0, 114.41],
"_Test Account VAT - _TC": [0.0, 114.41],
si.debit_to: [1500, 0.0],
round_off_account: [0.01, 0.01],
"Sales - _TC": [0.0, 1271.18],
"_Test Account Service Tax - _TC": [0.0, 114.50],
"_Test Account VAT - _TC": [0.0, 114.50],
si.debit_to: [1501, 0.0],
round_off_account: [0.20, 0.0],
"Sales - _TC": [0.0, 1272.20],
}
gl_entries = frappe.db.sql(
@@ -2267,7 +2275,8 @@ class TestSalesInvoice(FrappeTestCase):
si.save()
si.submit()
self.assertEqual(si.net_total, 4007.16)
self.assertEqual(si.net_total, si.base_net_total)
self.assertEqual(si.net_total, 4007.15)
self.assertEqual(si.grand_total, 4488.02)
self.assertEqual(si.total_taxes_and_charges, 480.86)
self.assertEqual(si.rounding_adjustment, -0.02)
@@ -2280,7 +2289,7 @@ class TestSalesInvoice(FrappeTestCase):
["_Test Account Service Tax - _TC", 0.0, 240.43],
["_Test Account VAT - _TC", 0.0, 240.43],
["Sales - _TC", 0.0, 4007.15],
[round_off_account, 0.02, 0.01],
[round_off_account, 0.01, 0.0],
]
)
@@ -3162,6 +3171,50 @@ class TestSalesInvoice(FrappeTestCase):
party_link.delete()
frappe.db.set_single_value("Accounts Settings", "enable_common_party_accounting", 0)
def test_sales_invoice_cancel_with_common_party_advance_jv(self):
from erpnext.accounts.doctype.opening_invoice_creation_tool.test_opening_invoice_creation_tool import (
make_customer,
)
from erpnext.accounts.doctype.party_link.party_link import create_party_link
from erpnext.buying.doctype.supplier.test_supplier import create_supplier
# create a customer
customer = make_customer(customer="_Test Common Supplier")
# create a supplier
supplier = create_supplier(supplier_name="_Test Common Supplier").name
# create a party link between customer & supplier
party_link = create_party_link("Supplier", supplier, customer)
# enable common party accounting
frappe.db.set_single_value("Accounts Settings", "enable_common_party_accounting", 1)
# create a sales invoice
si = create_sales_invoice(customer=customer)
# check creation of journal entry
jv = frappe.db.get_value(
"Journal Entry Account",
filters={
"reference_type": si.doctype,
"reference_name": si.name,
"docstatus": 1,
},
fieldname="parent",
)
self.assertTrue(jv)
# cancel sales invoice
si.cancel()
# check cancellation of journal entry
jv_status = frappe.db.get_value("Journal Entry", jv, "docstatus")
self.assertEqual(jv_status, 2)
party_link.delete()
frappe.db.set_single_value("Accounts Settings", "enable_common_party_accounting", 0)
def test_payment_statuses(self):
from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry
@@ -3871,6 +3924,313 @@ class TestSalesInvoice(FrappeTestCase):
self.assertEqual(len(res), 1)
self.assertEqual(res[0][0], pos_return.return_against)
@change_settings("Accounts Settings", {"enable_common_party_accounting": True})
def test_common_party_with_foreign_currency_jv(self):
from erpnext.accounts.doctype.account.test_account import create_account
from erpnext.accounts.doctype.opening_invoice_creation_tool.test_opening_invoice_creation_tool import (
make_customer,
)
from erpnext.accounts.doctype.party_link.party_link import create_party_link
from erpnext.buying.doctype.supplier.test_supplier import create_supplier
from erpnext.setup.utils import get_exchange_rate
creditors = create_account(
account_name="Creditors USD",
parent_account="Accounts Payable - _TC",
company="_Test Company",
account_currency="USD",
account_type="Payable",
)
debtors = create_account(
account_name="Debtors USD",
parent_account="Accounts Receivable - _TC",
company="_Test Company",
account_currency="USD",
account_type="Receivable",
)
# create a customer
customer = make_customer(customer="_Test Common Party USD")
cust_doc = frappe.get_doc("Customer", customer)
cust_doc.default_currency = "USD"
test_account_details = {
"company": "_Test Company",
"account": debtors,
}
cust_doc.append("accounts", test_account_details)
cust_doc.save()
# create a supplier
supplier = create_supplier(supplier_name="_Test Common Party USD").name
supp_doc = frappe.get_doc("Supplier", supplier)
supp_doc.default_currency = "USD"
test_account_details = {
"company": "_Test Company",
"account": creditors,
}
supp_doc.append("accounts", test_account_details)
supp_doc.save()
# create a party link between customer & supplier
create_party_link("Supplier", supplier, customer)
# create a sales invoice
si = create_sales_invoice(
customer=customer,
currency="USD",
conversion_rate=get_exchange_rate("USD", "INR"),
debit_to=debtors,
do_not_save=1,
)
si.party_account_currency = "USD"
si.save()
si.submit()
# check outstanding of sales invoice
si.reload()
self.assertEqual(si.status, "Paid")
self.assertEqual(flt(si.outstanding_amount), 0.0)
# check creation of journal entry
jv = frappe.get_all(
"Journal Entry Account",
{
"account": si.debit_to,
"party_type": "Customer",
"party": si.customer,
"reference_type": si.doctype,
"reference_name": si.name,
},
pluck="credit_in_account_currency",
)
self.assertTrue(jv)
self.assertEqual(jv[0], si.grand_total)
def test_invoice_remarks(self):
si = frappe.copy_doc(test_records[0])
si.po_no = "Test PO"
si.po_date = nowdate()
si.save()
si.submit()
self.assertEqual(si.remarks, f"Against Customer Order Test PO dated {format_date(nowdate())}")
def test_gl_voucher_subtype(self):
si = create_sales_invoice()
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
pluck="voucher_subtype",
)
self.assertTrue(all([x == "Sales Invoice" for x in gl_entries]))
si = create_sales_invoice(is_return=1, qty=-1)
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
pluck="voucher_subtype",
)
self.assertTrue(all([x == "Credit Note" for x in gl_entries]))
def test_validation_on_opening_invoice_with_rounding(self):
si = create_sales_invoice(qty=1, rate=99.98, do_not_submit=True)
si.is_opening = "Yes"
si.items[0].income_account = "Temporary Opening - _TC"
si.save()
self.assertRaises(frappe.ValidationError, si.submit)
def _create_opening_roundoff_account(self, company_name):
liability_root = frappe.db.get_all(
"Account",
filters={"company": company_name, "root_type": "Liability", "disabled": 0},
order_by="lft",
limit=1,
)[0]
# setup round off account
if acc := frappe.db.exists(
"Account",
{
"account_name": "Round Off for Opening",
"account_type": "Round Off for Opening",
"company": company_name,
},
):
frappe.db.set_value("Company", company_name, "round_off_for_opening", acc)
else:
acc = frappe.new_doc("Account")
acc.company = company_name
acc.parent_account = liability_root.name
acc.account_name = "Round Off for Opening"
acc.account_type = "Round Off for Opening"
acc.save()
frappe.db.set_value("Company", company_name, "round_off_for_opening", acc.name)
def test_opening_invoice_with_rounding_adjustment(self):
si = create_sales_invoice(qty=1, rate=99.98, do_not_submit=True)
si.is_opening = "Yes"
si.items[0].income_account = "Temporary Opening - _TC"
si.save()
self._create_opening_roundoff_account(si.company)
si.reload()
si.submit()
res = frappe.db.get_all(
"GL Entry",
filters={"voucher_no": si.name, "is_opening": "Yes"},
fields=["account", "debit", "credit", "is_opening"],
)
self.assertEqual(len(res), 3)
def _create_opening_invoice_with_inclusive_tax(self):
si = create_sales_invoice(qty=1, rate=90, do_not_submit=True)
si.is_opening = "Yes"
si.items[0].income_account = "Temporary Opening - _TC"
item_template = si.items[0].as_dict()
item_template.name = None
item_template.rate = 55
si.append("items", item_template)
si.append(
"taxes",
{
"charge_type": "On Net Total",
"account_head": "_Test Account Service Tax - _TC",
"cost_center": "_Test Cost Center - _TC",
"description": "Testing...",
"rate": 5,
"included_in_print_rate": True,
},
)
# there will be 0.01 precision loss between Dr and Cr
# caused by 'included_in_print_tax' option
si.save()
return si
def test_rounding_validation_for_opening_with_inclusive_tax(self):
si = self._create_opening_invoice_with_inclusive_tax()
# 'Round Off for Opening' not set in Company master
# Ledger level validation must be thrown
self.assertRaises(frappe.ValidationError, si.submit)
def test_ledger_entries_on_opening_invoice_with_rounding_loss_by_inclusive_tax(self):
si = self._create_opening_invoice_with_inclusive_tax()
# 'Round Off for Opening' is set in Company master
self._create_opening_roundoff_account(si.company)
si.submit()
actual = frappe.db.get_all(
"GL Entry",
filters={"voucher_no": si.name, "is_opening": "Yes", "is_cancelled": False},
fields=["account", "debit", "credit", "is_opening"],
order_by="account,debit",
)
expected = [
{"account": "_Test Account Service Tax - _TC", "debit": 0.0, "credit": 6.9, "is_opening": "Yes"},
{"account": "Debtors - _TC", "debit": 145.0, "credit": 0.0, "is_opening": "Yes"},
{"account": "Round Off for Opening - _TC", "debit": 0.0, "credit": 0.01, "is_opening": "Yes"},
{"account": "Temporary Opening - _TC", "debit": 0.0, "credit": 138.09, "is_opening": "Yes"},
]
self.assertEqual(len(actual), 4)
self.assertEqual(expected, actual)
@change_settings("Accounts Settings", {"enable_common_party_accounting": True})
def test_common_party_with_different_currency_in_debtor_and_creditor(self):
from erpnext.accounts.doctype.account.test_account import create_account
from erpnext.accounts.doctype.opening_invoice_creation_tool.test_opening_invoice_creation_tool import (
make_customer,
)
from erpnext.accounts.doctype.party_link.party_link import create_party_link
from erpnext.buying.doctype.supplier.test_supplier import create_supplier
from erpnext.setup.utils import get_exchange_rate
creditors = create_account(
account_name="Creditors INR",
parent_account="Accounts Payable - _TC",
company="_Test Company",
account_currency="INR",
account_type="Payable",
)
debtors = create_account(
account_name="Debtors USD",
parent_account="Accounts Receivable - _TC",
company="_Test Company",
account_currency="USD",
account_type="Receivable",
)
# create a customer
customer = make_customer(customer="_Test Common Party USD")
cust_doc = frappe.get_doc("Customer", customer)
cust_doc.default_currency = "USD"
test_account_details = {
"company": "_Test Company",
"account": debtors,
}
cust_doc.append("accounts", test_account_details)
cust_doc.save()
# create a supplier
supplier = create_supplier(supplier_name="_Test Common Party INR").name
supp_doc = frappe.get_doc("Supplier", supplier)
supp_doc.default_currency = "INR"
test_account_details = {
"company": "_Test Company",
"account": creditors,
}
supp_doc.append("accounts", test_account_details)
supp_doc.save()
# create a party link between customer & supplier
create_party_link("Supplier", supplier, customer)
# create a sales invoice
si = create_sales_invoice(
customer=customer,
currency="USD",
conversion_rate=get_exchange_rate("USD", "INR"),
debit_to=debtors,
do_not_save=1,
)
si.party_account_currency = "USD"
si.save()
si.submit()
# check outstanding of sales invoice
si.reload()
self.assertEqual(si.status, "Paid")
self.assertEqual(flt(si.outstanding_amount), 0.0)
# check creation of journal entry
jv = frappe.get_all(
"Journal Entry Account",
{
"account": si.debit_to,
"party_type": "Customer",
"party": si.customer,
"reference_type": si.doctype,
"reference_name": si.name,
},
pluck="credit_in_account_currency",
)
self.assertTrue(jv)
self.assertEqual(jv[0], si.grand_total)
def test_total_billed_amount(self):
si = create_sales_invoice(do_not_submit=True)
project = frappe.new_doc("Project")
project.project_name = "Test Total Billed Amount"
project.save()
si.project = project.name
si.save()
si.submit()
doc = frappe.get_doc("Project", project.name)
self.assertEqual(doc.total_billed_amount, si.grand_total)
def set_advance_flag(company, flag, default_account):
frappe.db.set_value(

View File

@@ -89,11 +89,14 @@
"incoming_rate",
"item_tax_rate",
"actual_batch_qty",
"actual_qty",
"section_break_eoec",
"serial_no",
"column_break_ytgd",
"batch_no",
"available_quantity_section",
"actual_qty",
"column_break_ogff",
"company_total_stock",
"edit_references",
"sales_order",
"so_detail",
@@ -675,7 +678,8 @@
"allow_on_submit": 1,
"fieldname": "actual_qty",
"fieldtype": "Float",
"label": "Available Qty at Warehouse",
"label": "Qty (Warehouse)",
"no_copy": 1,
"oldfieldname": "actual_qty",
"oldfieldtype": "Currency",
"print_hide": 1,
@@ -812,7 +816,8 @@
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
"options": "Project",
"search_index": 1
},
{
"depends_on": "eval:parent.update_stock == 1",
@@ -922,12 +927,30 @@
{
"fieldname": "column_break_ytgd",
"fieldtype": "Column Break"
},
{
"fieldname": "available_quantity_section",
"fieldtype": "Section Break",
"label": "Available Quantity"
},
{
"fieldname": "column_break_ogff",
"fieldtype": "Column Break"
},
{
"allow_on_submit": 1,
"fieldname": "company_total_stock",
"fieldtype": "Float",
"label": "Qty (Company)",
"no_copy": 1,
"print_hide": 1,
"read_only": 1
}
],
"idx": 1,
"istable": 1,
"links": [],
"modified": "2024-05-23 16:36:18.970862",
"modified": "2024-11-25 16:27:33.287341",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice Item",

View File

@@ -28,6 +28,7 @@ class SalesInvoiceItem(Document):
base_rate_with_margin: DF.Currency
batch_no: DF.Link | None
brand: DF.Data | None
company_total_stock: DF.Float
conversion_factor: DF.Float
cost_center: DF.Link
customer_item_code: DF.Data | None

View File

@@ -737,10 +737,7 @@ class Subscription(Document):
elif self.generate_invoice_at == "Days before the current subscription period":
processing_date = add_days(self.current_invoice_start, -self.number_of_days)
process_subscription = frappe.new_doc("Process Subscription")
process_subscription.posting_date = processing_date
process_subscription.subscription = self.name
process_subscription.save().submit()
self.process(posting_date=processing_date)
def is_prorate() -> int:

View File

@@ -185,7 +185,7 @@ def get_tax_template(posting_date, args):
conditions.append("(from_date is null) and (to_date is null)")
conditions.append(
"ifnull(tax_category, '') = {}".format(frappe.db.escape(cstr(args.get("tax_category"))))
"ifnull(tax_category, '') = {}".format(frappe.db.escape(cstr(args.get("tax_category")), False))
)
if "tax_category" in args.keys():
del args["tax_category"]

View File

@@ -327,7 +327,7 @@ def get_tax_amount(party_type, parties, inv, tax_details, posting_date, pan_no=N
tax_amount = 0
else:
# if no TCS has been charged in FY,
# then chargeable value is "prev invoices + advances" value which cross the threshold
# 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):
@@ -414,6 +414,9 @@ def get_advance_vouchers(parties, company=None, from_date=None, to_date=None, pa
Use Payment Ledger to fetch unallocated Advance Payments
"""
if party_type == "Supplier":
return []
ple = qb.DocType("Payment Ledger Entry")
conditions = []
@@ -511,7 +514,7 @@ def get_tds_amount(ldc, parties, inv, tax_details, vouchers):
payment_entry_filters.pop("apply_tax_withholding_amount", None)
payment_entry_filters.pop("tax_withholding_category", None)
supp_credit_amt = frappe.db.get_value("Purchase Invoice", invoice_filters, field) or 0.0
supp_inv_credit_amt = frappe.db.get_value("Purchase Invoice", invoice_filters, field) or 0.0
supp_jv_credit_amt = (
frappe.db.get_value(
@@ -535,7 +538,7 @@ def get_tds_amount(ldc, parties, inv, tax_details, vouchers):
group_by="payment_type",
)
supp_credit_amt += supp_jv_credit_amt
supp_credit_amt = supp_jv_credit_amt
supp_credit_amt += inv.tax_withholding_net_total
for type in payment_entry_amounts:
@@ -553,19 +556,19 @@ def get_tds_amount(ldc, parties, inv, tax_details, vouchers):
tax_withholding_net_total = inv.tax_withholding_net_total
if (threshold and tax_withholding_net_total >= threshold) or (
cumulative_threshold and supp_credit_amt >= cumulative_threshold
cumulative_threshold and (supp_credit_amt + supp_inv_credit_amt) >= cumulative_threshold
):
# Get net total again as TDS is calculated on net total
# Grand is used to just check for threshold breach
net_total = (
frappe.db.get_value("Purchase Invoice", invoice_filters, "sum(tax_withholding_net_total)") or 0.0
)
supp_credit_amt += net_total
if (cumulative_threshold and supp_credit_amt >= cumulative_threshold) and cint(
tax_details.tax_on_excess_amount
):
# Get net total again as TDS is calculated on net total
# Grand is used to just check for threshold breach
net_total = (
frappe.db.get_value("Purchase Invoice", invoice_filters, "sum(tax_withholding_net_total)")
or 0.0
)
net_total += inv.tax_withholding_net_total
supp_credit_amt = net_total - cumulative_threshold
supp_credit_amt = 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(
@@ -607,8 +610,6 @@ def get_tcs_amount(parties, inv, tax_details, vouchers, adv_vouchers):
conditions.append(ple.voucher_no == ple.against_voucher_no)
conditions.append(ple.company == inv.company)
(qb.from_(ple).select(Abs(Sum(ple.amount))).where(Criterion.all(conditions)).run(as_list=1))
advance_amt = (
qb.from_(ple).select(Abs(Sum(ple.amount))).where(Criterion.all(conditions)).run()[0][0] or 0.0
)
@@ -631,9 +632,12 @@ def get_tcs_amount(parties, inv, tax_details, vouchers, adv_vouchers):
)
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
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
@@ -642,6 +646,14 @@ def get_tcs_amount(parties, inv, tax_details, vouchers, adv_vouchers):
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

View File

@@ -74,11 +74,17 @@ class TestTaxWithholdingCategory(FrappeTestCase):
self.assertEqual(pi.grand_total, 18000)
# check gl entry for the purchase invoice
gl_entries = frappe.db.get_all("GL Entry", filters={"voucher_no": pi.name}, fields=["*"])
gl_entries = frappe.db.get_all(
"GL Entry",
filters={"voucher_no": pi.name},
fields=["account", "sum(debit) as debit", "sum(credit) as credit"],
group_by="account",
)
self.assertEqual(len(gl_entries), 3)
for d in gl_entries:
if d.account == pi.credit_to:
self.assertEqual(d.credit, 18000)
self.assertEqual(d.credit, 20000)
self.assertEqual(d.debit, 2000)
elif d.account == pi.items[0].get("expense_account"):
self.assertEqual(d.debit, 20000)
elif d.account == pi.taxes[0].get("account_head"):
@@ -121,6 +127,85 @@ class TestTaxWithholdingCategory(FrappeTestCase):
for d in reversed(invoices):
d.cancel()
def test_cumulative_threshold_with_party_ledger_amount_on_net_total(self):
invoices = []
frappe.db.set_value(
"Supplier", "Test TDS Supplier3", "tax_withholding_category", "Advance TDS Category"
)
# Invoice with tax and without exceeding single and cumulative thresholds
for _ in range(2):
pi = create_purchase_invoice(supplier="Test TDS Supplier3", rate=1000, do_not_save=True)
pi.apply_tds = 1
pi.append(
"taxes",
{
"category": "Total",
"charge_type": "Actual",
"account_head": "_Test Account VAT - _TC",
"cost_center": "Main - _TC",
"tax_amount": 500,
"description": "Test",
"add_deduct_tax": "Add",
},
)
pi.save()
pi.submit()
invoices.append(pi)
# Third Invoice exceeds single threshold and not exceeding cumulative threshold
pi1 = create_purchase_invoice(supplier="Test TDS Supplier3", rate=6000)
pi1.apply_tds = 1
pi1.save()
pi1.submit()
invoices.append(pi1)
# Cumulative threshold is 10,000
# Threshold calculation should be only on the third invoice
self.assertEqual(pi1.taxes[0].tax_amount, 800)
for d in reversed(invoices):
d.cancel()
def test_cumulative_threshold_with_tax_on_excess_amount(self):
invoices = []
frappe.db.set_value("Supplier", "Test TDS Supplier3", "tax_withholding_category", "New TDS Category")
# Invoice with tax and without exceeding single and cumulative thresholds
for _ in range(2):
pi = create_purchase_invoice(supplier="Test TDS Supplier3", rate=10000, do_not_save=True)
pi.apply_tds = 1
pi.append(
"taxes",
{
"category": "Total",
"charge_type": "Actual",
"account_head": "_Test Account VAT - _TC",
"cost_center": "Main - _TC",
"tax_amount": 500,
"description": "Test",
"add_deduct_tax": "Add",
},
)
pi.save()
pi.submit()
invoices.append(pi)
# Third Invoice exceeds single threshold and not exceeding cumulative threshold
pi1 = create_purchase_invoice(supplier="Test TDS Supplier3", rate=20000)
pi1.apply_tds = 1
pi1.save()
pi1.submit()
invoices.append(pi1)
# Cumulative threshold is 10,000
# Threshold calculation should be only on the third invoice
self.assertTrue(len(pi1.taxes) > 0)
self.assertEqual(pi1.taxes[0].tax_amount, 1000)
for d in reversed(invoices):
d.cancel()
def test_cumulative_threshold_tcs(self):
frappe.db.set_value(
"Customer", "Test TCS Customer", "tax_withholding_category", "Cumulative Threshold TCS"
@@ -210,6 +295,46 @@ class TestTaxWithholdingCategory(FrappeTestCase):
d.reload()
d.cancel()
def test_tcs_on_allocated_advance_payments(self):
frappe.db.set_value(
"Customer", "Test TCS Customer", "tax_withholding_category", "Cumulative Threshold TCS"
)
vouchers = []
# create advance payment
pe = create_payment_entry(
payment_type="Receive", party_type="Customer", party="Test TCS Customer", paid_amount=30000
)
pe.paid_from = "Debtors - _TC"
pe.paid_to = "Cash - _TC"
pe.submit()
vouchers.append(pe)
si = create_sales_invoice(customer="Test TCS Customer", rate=50000)
advances = si.get_advance_entries()
si.append(
"advances",
{
"reference_type": advances[0].reference_type,
"reference_name": advances[0].reference_name,
"advance_amount": advances[0].amount,
"allocated_amount": 30000,
},
)
si.submit()
vouchers.append(si)
# assert tax collection on total invoice ,advance payment adjusted should be excluded.
tcs_charged = sum([d.base_tax_amount for d in si.taxes if d.account_head == "TCS - _TC"])
# tcs = (inv amt)50000+(adv amt)30000-(adv adj) 30000 - threshold(30000) * rate 10%
self.assertEqual(tcs_charged, 2000)
# cancel invoice and payments to avoid clashing
for d in reversed(vouchers):
d.reload()
d.cancel()
def test_tds_calculation_on_net_total(self):
frappe.db.set_value(
"Supplier", "Test TDS Supplier4", "tax_withholding_category", "Cumulative Threshold TDS"

View File

@@ -7,7 +7,9 @@ from frappe.utils import today
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.party import get_party_account
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
@@ -260,6 +262,7 @@ class TestUnreconcilePayment(AccountsTestMixin, FrappeTestCase):
pe1.paid_from = self.debtors_usd
pe1.paid_from_account_currency = "USD"
pe1.source_exchange_rate = 75
pe1.paid_amount = 100
pe1.received_amount = 75 * 100
pe1.save()
# Allocate payment against both invoices
@@ -277,6 +280,7 @@ class TestUnreconcilePayment(AccountsTestMixin, FrappeTestCase):
pe2.paid_from = self.debtors_usd
pe2.paid_from_account_currency = "USD"
pe2.source_exchange_rate = 75
pe2.paid_amount = 100
pe2.received_amount = 75 * 100
pe2.save()
# Allocate payment against both invoices
@@ -360,6 +364,107 @@ class TestUnreconcilePayment(AccountsTestMixin, FrappeTestCase):
# Assert 'Advance Paid'
so.reload()
pe.reload()
self.assertEqual(so.advance_paid, 0)
self.assertEqual(so.advance_paid, 100)
self.assertEqual(len(pe.references), 0)
self.assertEqual(pe.unallocated_amount, 100)
pe.cancel()
so.reload()
self.assertEqual(so.advance_paid, 100)
def test_06_unreconcile_advance_from_payment_entry(self):
self.enable_advance_as_liability()
so1 = self.create_sales_order()
so2 = self.create_sales_order()
pe = self.create_payment_entry()
# Allocation payment against Sales Order
pe.paid_amount = 260
pe.append(
"references",
{"reference_doctype": so1.doctype, "reference_name": so1.name, "allocated_amount": 150},
)
pe.append(
"references",
{"reference_doctype": so2.doctype, "reference_name": so2.name, "allocated_amount": 110},
)
pe.save().submit()
# Assert 'Advance Paid'
so1.reload()
self.assertEqual(so1.advance_paid, 150)
so2.reload()
self.assertEqual(so2.advance_paid, 110)
unreconcile = frappe.get_doc(
{
"doctype": "Unreconcile Payment",
"company": self.company,
"voucher_type": pe.doctype,
"voucher_no": pe.name,
}
)
unreconcile.add_references()
self.assertEqual(len(unreconcile.allocations), 2)
allocations = [(x.reference_name, x.allocated_amount) for x in unreconcile.allocations]
self.assertListEqual(allocations, [(so1.name, 150), (so2.name, 110)])
# unreconcile so2
unreconcile.remove(unreconcile.allocations[0])
unreconcile.save().submit()
# Assert 'Advance Paid'
so1.reload()
so2.reload()
pe.reload()
self.assertEqual(so1.advance_paid, 150)
self.assertEqual(so2.advance_paid, 110)
self.assertEqual(len(pe.references), 1)
self.assertEqual(pe.unallocated_amount, 110)
self.disable_advance_as_liability()
def test_07_adv_from_so_to_invoice(self):
self.enable_advance_as_liability()
so = self.create_sales_order()
pe = self.create_payment_entry()
pe.paid_amount = 1000
pe.append(
"references",
{"reference_doctype": so.doctype, "reference_name": so.name, "allocated_amount": 1000},
)
pe.save().submit()
# Assert 'Advance Paid'
so.reload()
self.assertEqual(so.advance_paid, 1000)
si = make_sales_invoice(so.name)
si.insert().submit()
pr = frappe.get_doc(
{
"doctype": "Payment Reconciliation",
"company": self.company,
"party_type": "Customer",
"party": so.customer,
}
)
accounts = get_party_account("Customer", so.customer, so.company, True)
pr.receivable_payable_account = accounts[0]
pr.default_advance_account = accounts[1]
pr.get_unreconciled_entries()
self.assertEqual(len(pr.get("invoices")), 1)
self.assertEqual(len(pr.get("payments")), 1)
invoices = [x.as_dict() for x in pr.get("invoices")]
payments = [x.as_dict() for x in pr.get("payments")]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
pr.reconcile()
self.assertEqual(len(pr.get("invoices")), 0)
self.assertEqual(len(pr.get("payments")), 0)
# Assert 'Advance Paid'
so.reload()
self.assertEqual(so.advance_paid, 1000)
self.disable_advance_as_liability()

View File

@@ -1,7 +1,5 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "format:UNREC-{#####}",
"creation": "2023-08-22 10:26:34.421423",
"default_view": "List",
"doctype": "DocType",
@@ -58,11 +56,10 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2023-08-28 17:42:50.261377",
"modified": "2024-10-10 12:03:50.022444",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Unreconcile Payment",
"naming_rule": "Expression",
"owner": "Administrator",
"permissions": [
{

View File

@@ -7,7 +7,7 @@ import copy
import frappe
from frappe import _
from frappe.model.meta import get_field_precision
from frappe.utils import cint, flt, formatdate, getdate, now
from frappe.utils import cint, flt, formatdate, get_link_to_form, getdate, now
import erpnext
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
@@ -37,13 +37,14 @@ def make_gl_entries(
validate_disabled_accounts(gl_map)
gl_map = process_gl_map(gl_map, merge_entries)
if gl_map and len(gl_map) > 1:
create_payment_ledger_entry(
gl_map,
cancel=0,
adv_adj=adv_adj,
update_outstanding=update_outstanding,
from_repost=from_repost,
)
if gl_map[0].voucher_type != "Period Closing Voucher":
create_payment_ledger_entry(
gl_map,
cancel=0,
adv_adj=adv_adj,
update_outstanding=update_outstanding,
from_repost=from_repost,
)
save_entries(gl_map, adv_adj, update_outstanding, from_repost)
# Post GL Map proccess there may no be any GL Entries
elif gl_map:
@@ -116,17 +117,16 @@ def get_accounting_dimensions_for_offsetting_entry(gl_map, company):
def validate_disabled_accounts(gl_map):
accounts = [d.account for d in gl_map if d.account]
Account = frappe.qb.DocType("Account")
disabled_accounts = frappe.get_all(
"Account",
filters={"disabled": 1, "is_group": 0, "company": gl_map[0].company},
fields=["name"],
)
disabled_accounts = (
frappe.qb.from_(Account)
.where(Account.name.isin(accounts) & Account.disabled == 1)
.select(Account.name, Account.disabled)
).run(as_dict=True)
if disabled_accounts:
used_disabled_accounts = set(accounts).intersection(set([d.name for d in disabled_accounts]))
if used_disabled_accounts:
account_list = "<br>"
account_list += ", ".join([frappe.bold(d.name) for d in disabled_accounts])
account_list += ", ".join([frappe.bold(d) for d in used_disabled_accounts])
frappe.throw(
_("Cannot create accounting entries against disabled accounts: {0}").format(account_list),
title=_("Disabled Account Selected"),
@@ -179,50 +179,53 @@ def process_gl_map(gl_map, merge_entries=True, precision=None):
def distribute_gl_based_on_cost_center_allocation(gl_map, precision=None):
cost_center_allocation = get_cost_center_allocation_data(gl_map[0]["company"], gl_map[0]["posting_date"])
if not cost_center_allocation:
return gl_map
new_gl_map = []
for d in gl_map:
cost_center = d.get("cost_center")
# Validate budget against main cost center
validate_expense_against_budget(d, expense_amount=flt(d.debit, precision) - flt(d.credit, precision))
if cost_center and cost_center_allocation.get(cost_center):
for sub_cost_center, percentage in cost_center_allocation.get(cost_center, {}).items():
gle = copy.deepcopy(d)
gle.cost_center = sub_cost_center
for field in ("debit", "credit", "debit_in_account_currency", "credit_in_account_currency"):
gle[field] = flt(flt(d.get(field)) * percentage / 100, precision)
new_gl_map.append(gle)
else:
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
for sub_cost_center, percentage in cost_center_allocation:
gle = copy.deepcopy(d)
gle.cost_center = sub_cost_center
for field in ("debit", "credit", "debit_in_account_currency", "credit_in_account_currency"):
gle[field] = flt(flt(d.get(field)) * percentage / 100, precision)
new_gl_map.append(gle)
return new_gl_map
def get_cost_center_allocation_data(company, posting_date):
par = frappe.qb.DocType("Cost Center Allocation")
child = frappe.qb.DocType("Cost Center Allocation Percentage")
def get_cost_center_allocation_data(company, posting_date, cost_center):
cost_center_allocation = frappe.db.get_value(
"Cost Center Allocation",
{
"docstatus": 1,
"company": company,
"valid_from": ("<=", posting_date),
"main_cost_center": cost_center,
},
pluck="name",
order_by="valid_from desc",
)
records = (
frappe.qb.from_(par)
.inner_join(child)
.on(par.name == child.parent)
.select(par.main_cost_center, child.cost_center, child.percentage)
.where(par.docstatus == 1)
.where(par.company == company)
.where(par.valid_from <= posting_date)
.orderby(par.valid_from, order=frappe.qb.desc)
).run(as_dict=True)
if not cost_center_allocation:
return []
cc_allocation = frappe._dict()
for d in records:
cc_allocation.setdefault(d.main_cost_center, frappe._dict()).setdefault(d.cost_center, d.percentage)
records = frappe.db.get_all(
"Cost Center Allocation Percentage",
{"parent": cost_center_allocation},
["cost_center", "percentage"],
as_list=True,
)
return cc_allocation
return records
def merge_similar_entries(gl_map, precision=None):
@@ -231,6 +234,10 @@ def merge_similar_entries(gl_map, precision=None):
merge_properties = get_merge_properties(accounting_dimensions)
for entry in gl_map:
if entry._skip_merge:
merged_gl_map.append(entry)
continue
entry.merge_key = get_merge_key(entry, merge_properties)
# if there is already an entry in this account then just add it
# to that entry
@@ -308,66 +315,48 @@ def check_if_in_list(gle, gl_map):
def toggle_debit_credit_if_negative(gl_map):
debit_credit_field_map = {
"debit": "credit",
"debit_in_account_currency": "credit_in_account_currency",
"debit_in_transaction_currency": "credit_in_transaction_currency",
}
for entry in gl_map:
# toggle debit, credit if negative entry
if flt(entry.debit) < 0 and flt(entry.credit) < 0 and flt(entry.debit) == flt(entry.credit):
entry.credit *= -1
entry.debit *= -1
for debit_field, credit_field in debit_credit_field_map.items():
debit = flt(entry.get(debit_field))
credit = flt(entry.get(credit_field))
if (
flt(entry.debit_in_account_currency) < 0
and flt(entry.credit_in_account_currency) < 0
and flt(entry.debit_in_account_currency) == flt(entry.credit_in_account_currency)
):
entry.credit_in_account_currency *= -1
entry.debit_in_account_currency *= -1
if debit < 0 and credit < 0 and debit == credit:
debit *= -1
credit *= -1
if flt(entry.debit) < 0:
entry.credit = flt(entry.credit) - flt(entry.debit)
entry.debit = 0.0
if debit < 0:
credit = credit - debit
debit = 0.0
if flt(entry.debit_in_account_currency) < 0:
entry.credit_in_account_currency = flt(entry.credit_in_account_currency) - flt(
entry.debit_in_account_currency
)
entry.debit_in_account_currency = 0.0
if credit < 0:
debit = debit - credit
credit = 0.0
if flt(entry.credit) < 0:
entry.debit = flt(entry.debit) - flt(entry.credit)
entry.credit = 0.0
# update net values
# In some scenarios net value needs to be shown in the ledger
# This method updates net values as debit or credit
if entry.post_net_value and debit and credit:
if debit > credit:
debit = debit - credit
credit = 0.0
if flt(entry.credit_in_account_currency) < 0:
entry.debit_in_account_currency = flt(entry.debit_in_account_currency) - flt(
entry.credit_in_account_currency
)
entry.credit_in_account_currency = 0.0
else:
credit = credit - debit
debit = 0.0
update_net_values(entry)
entry[debit_field] = debit
entry[credit_field] = credit
return gl_map
def update_net_values(entry):
# In some scenarios net value needs to be shown in the ledger
# This method updates net values as debit or credit
if entry.post_net_value and entry.debit and entry.credit:
if entry.debit > entry.credit:
entry.debit = entry.debit - entry.credit
entry.debit_in_account_currency = (
entry.debit_in_account_currency - entry.credit_in_account_currency
)
entry.credit = 0
entry.credit_in_account_currency = 0
else:
entry.credit = entry.credit - entry.debit
entry.credit_in_account_currency = (
entry.credit_in_account_currency - entry.debit_in_account_currency
)
entry.debit = 0
entry.debit_in_account_currency = 0
def save_entries(gl_map, adv_adj, update_outstanding, from_repost=False):
if not from_repost:
validate_cwip_accounts(gl_map)
@@ -489,16 +478,36 @@ def raise_debit_credit_not_equal_error(debit_credit_diff, voucher_type, voucher_
)
def has_opening_entries(gl_map: list) -> bool:
for x in gl_map:
if x.is_opening == "Yes":
return True
return False
def make_round_off_gle(gl_map, debit_credit_diff, precision):
round_off_account, round_off_cost_center = get_round_off_account_and_cost_center(
round_off_account, round_off_cost_center, round_off_for_opening = get_round_off_account_and_cost_center(
gl_map[0].company, gl_map[0].voucher_type, gl_map[0].voucher_no
)
round_off_gle = frappe._dict()
round_off_account_exists = False
has_opening_entry = has_opening_entries(gl_map)
if has_opening_entry:
if not round_off_for_opening:
frappe.throw(
_("Please set '{0}' in Company: {1}").format(
frappe.bold("Round Off for Opening"), get_link_to_form("Company", gl_map[0].company)
)
)
account = round_off_for_opening
else:
account = round_off_account
if gl_map[0].voucher_type != "Period Closing Voucher":
for d in gl_map:
if d.account == round_off_account:
if d.account == account:
round_off_gle = d
if d.debit:
debit_credit_diff -= flt(d.debit) - flt(d.credit)
@@ -516,7 +525,7 @@ def make_round_off_gle(gl_map, debit_credit_diff, precision):
round_off_gle.update(
{
"account": round_off_account,
"account": account,
"debit_in_account_currency": abs(debit_credit_diff) if debit_credit_diff < 0 else 0,
"credit_in_account_currency": debit_credit_diff if debit_credit_diff > 0 else 0,
"debit": abs(debit_credit_diff) if debit_credit_diff < 0 else 0,
@@ -530,6 +539,9 @@ def make_round_off_gle(gl_map, debit_credit_diff, precision):
}
)
if has_opening_entry:
round_off_gle.update({"is_opening": "Yes"})
update_accounting_dimensions(round_off_gle)
if not round_off_account_exists:
gl_map.append(round_off_gle)
@@ -554,9 +566,9 @@ def update_accounting_dimensions(round_off_gle):
def get_round_off_account_and_cost_center(company, voucher_type, voucher_no, use_company_default=False):
round_off_account, round_off_cost_center = frappe.get_cached_value(
"Company", company, ["round_off_account", "round_off_cost_center"]
) or [None, None]
round_off_account, round_off_cost_center, round_off_for_opening = frappe.get_cached_value(
"Company", company, ["round_off_account", "round_off_cost_center", "round_off_for_opening"]
) or [None, None, None]
# Use expense account as fallback
if not round_off_account:
@@ -571,12 +583,20 @@ def get_round_off_account_and_cost_center(company, voucher_type, voucher_no, use
round_off_cost_center = parent_cost_center
if not round_off_account:
frappe.throw(_("Please mention Round Off Account in Company"))
frappe.throw(
_("Please mention '{0}' in Company: {1}").format(
frappe.bold("Round Off Account"), get_link_to_form("Company", company)
)
)
if not round_off_cost_center:
frappe.throw(_("Please mention Round Off Cost Center in Company"))
frappe.throw(
_("Please mention '{0}' in Company: {1}").format(
frappe.bold("Round Off Cost Center"), get_link_to_form("Company", company)
)
)
return round_off_account, round_off_cost_center
return round_off_account, round_off_cost_center, round_off_for_opening
def make_reverse_gl_entries(
@@ -705,7 +725,7 @@ def validate_against_pcv(is_opening, posting_date, company):
)
last_pcv_date = frappe.db.get_value(
"Period Closing Voucher", {"docstatus": 1, "company": company}, "max(posting_date)"
"Period Closing Voucher", {"docstatus": 1, "company": company}, "max(period_end_date)"
)
if last_pcv_date and getdate(posting_date) <= getdate(last_pcv_date):

View File

@@ -4,13 +4,14 @@
"docstatus": 0,
"doctype": "Number Card",
"document_type": "Purchase Invoice",
"dynamic_filters_json": "[[\"Purchase Invoice\",\"company\",\"=\",\" frappe.defaults.get_user_default(\\\"Company\\\")\"]]",
"filters_json": "[[\"Purchase Invoice\",\"docstatus\",\"=\",\"1\",false],[\"Purchase Invoice\",\"posting_date\",\"Timespan\",\"this year\",false]]",
"function": "Sum",
"idx": 0,
"is_public": 1,
"is_standard": 1,
"label": "Total Incoming Bills",
"modified": "2020-07-22 13:06:46.045344",
"modified": "2024-11-20 19:08:37.043777",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Total Incoming Bills",

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