diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js index 0701435dfc..35092a7c4d 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.js +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js @@ -122,13 +122,10 @@ frappe.ui.form.on('Payment Entry', { frm.set_query('payment_term', 'references', function(frm, cdt, cdn) { const child = locals[cdt][cdn]; if (in_list(['Purchase Invoice', 'Sales Invoice'], child.reference_doctype) && child.reference_name) { - let payment_term_list = frappe.get_list('Payment Schedule', {'parent': child.reference_name}); - - payment_term_list = payment_term_list.map(pt => pt.payment_term); - return { + query: "erpnext.controllers.queries.get_payment_terms_for_references", filters: { - 'name': ['in', payment_term_list] + 'reference': child.reference_name } } } @@ -1463,4 +1460,4 @@ frappe.ui.form.on('Payment Entry', { }); } }, -}) \ No newline at end of file +}) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 7542babe92..596881a4e0 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -207,6 +207,20 @@ 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 term_based_allocation_enabled_for_reference( + self, reference_doctype: str, reference_name: str + ) -> bool: + if ( + reference_doctype + and reference_doctype in ["Sales Invoice", "Sales Order", "Purchase Order", "Purchase Invoice"] + and reference_name + ): + if template := frappe.db.get_value(reference_doctype, reference_name, "payment_terms_template"): + return frappe.db.get_value( + "Payment Terms Template", template, "allocate_payment_based_on_payment_terms" + ) + return False + def validate_allocated_amount_with_latest_data(self): latest_references = get_outstanding_reference_documents( { @@ -228,10 +242,23 @@ class PaymentEntry(AccountsController): d = frappe._dict(d) latest_lookup.setdefault((d.voucher_type, d.voucher_no), frappe._dict())[d.payment_term] = d - for d in self.get("references"): - latest = (latest_lookup.get((d.reference_doctype, d.reference_name)) or frappe._dict()).get( - d.payment_term - ) + for idx, d in enumerate(self.get("references"), start=1): + latest = latest_lookup.get((d.reference_doctype, d.reference_name)) or frappe._dict() + + # If term based allocation is enabled, throw + if ( + d.payment_term is None or d.payment_term == "" + ) and self.term_based_allocation_enabled_for_reference( + d.reference_doctype, d.reference_name + ): + frappe.throw( + _( + "{0} has Payment Term based allocation enabled. Select a Payment Term for Row #{1} in Payment References section" + ).format(frappe.bold(d.reference_name), frappe.bold(idx)) + ) + + # if no payment template is used by invoice and has a custom term(no `payment_term`), then invoice outstanding will be in 'None' key + latest = latest.get(d.payment_term) or latest.get(None) # The reference has already been fully paid if not latest: @@ -1633,6 +1660,9 @@ def split_invoices_based_on_payment_terms(outstanding_invoices, company): "invoice_amount": flt(d.invoice_amount), "outstanding_amount": flt(d.outstanding_amount), "payment_term_outstanding": payment_term_outstanding, + "allocated_amount": payment_term_outstanding + if payment_term_outstanding + else d.outstanding_amount, "payment_amount": payment_term.payment_amount, "payment_term": payment_term.payment_term, "account": d.account, diff --git a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py index 70cc4b3d34..c6e93f3f7a 100644 --- a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py @@ -1061,6 +1061,101 @@ class TestPaymentEntry(FrappeTestCase): } self.assertDictEqual(ref_details, expected_response) + @change_settings( + "Accounts Settings", + { + "unlink_payment_on_cancellation_of_invoice": 1, + "delete_linked_ledger_entries": 1, + "allow_multi_currency_invoices_against_single_party_account": 1, + }, + ) + def test_overallocation_validation_on_payment_terms(self): + """ + Validate Allocation on Payment Entry based on Payment Schedule. Upon overallocation, validation error must be thrown. + + """ + customer = create_customer() + create_payment_terms_template() + + # Validate allocation on base/company currency + si1 = create_sales_invoice(do_not_save=1, qty=1, rate=200) + si1.payment_terms_template = "Test Receivable Template" + si1.save().submit() + + si1.reload() + pe = get_payment_entry(si1.doctype, si1.name).save() + # Allocated amount should be according to the payment schedule + for idx, schedule in enumerate(si1.payment_schedule): + with self.subTest(idx=idx): + self.assertEqual(flt(schedule.payment_amount), flt(pe.references[idx].allocated_amount)) + pe.save() + + # Overallocation validation should trigger + pe.paid_amount = 400 + pe.references[0].allocated_amount = 200 + pe.references[1].allocated_amount = 200 + self.assertRaises(frappe.ValidationError, pe.save) + pe.delete() + si1.cancel() + si1.delete() + + # Validate allocation on foreign currency + si2 = create_sales_invoice( + customer="_Test Customer USD", + debit_to="_Test Receivable USD - _TC", + currency="USD", + conversion_rate=80, + do_not_save=1, + ) + si2.payment_terms_template = "Test Receivable Template" + si2.save().submit() + + si2.reload() + pe = get_payment_entry(si2.doctype, si2.name).save() + # Allocated amount should be according to the payment schedule + for idx, schedule in enumerate(si2.payment_schedule): + with self.subTest(idx=idx): + self.assertEqual(flt(schedule.payment_amount), flt(pe.references[idx].allocated_amount)) + pe.save() + + # Overallocation validation should trigger + pe.paid_amount = 200 + pe.references[0].allocated_amount = 100 + pe.references[1].allocated_amount = 100 + self.assertRaises(frappe.ValidationError, pe.save) + pe.delete() + si2.cancel() + si2.delete() + + # Validate allocation in base/company currency on a foreign currency document + # when invoice is made is foreign currency, but posted to base/company currency debtors account + si3 = create_sales_invoice( + customer=customer, + currency="USD", + conversion_rate=80, + do_not_save=1, + ) + si3.payment_terms_template = "Test Receivable Template" + si3.save().submit() + + si3.reload() + pe = get_payment_entry(si3.doctype, si3.name).save() + # Allocated amount should be equal to payment term outstanding + self.assertEqual(len(pe.references), 2) + for idx, ref in enumerate(pe.references): + with self.subTest(idx=idx): + self.assertEqual(ref.payment_term_outstanding, ref.allocated_amount) + pe.save() + + # Overallocation validation should trigger + pe.paid_amount = 16000 + pe.references[0].allocated_amount = 8000 + pe.references[1].allocated_amount = 8000 + self.assertRaises(frappe.ValidationError, pe.save) + pe.delete() + si3.cancel() + si3.delete() + def create_payment_entry(**args): payment_entry = frappe.new_doc("Payment Entry") @@ -1150,3 +1245,17 @@ def create_payment_terms_template_with_discount( def create_payment_term(name): if not frappe.db.exists("Payment Term", name): frappe.get_doc({"doctype": "Payment Term", "payment_term_name": name}).insert() + + +def create_customer(name="_Test Customer 2 USD", currency="USD"): + customer = None + if frappe.db.exists("Customer", name): + customer = name + else: + customer = frappe.new_doc("Customer") + customer.customer_name = name + customer.default_currency = currency + customer.type = "Individual" + customer.save() + customer = customer.name + return customer diff --git a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py index 08f4cf45d6..6193c849b5 100644 --- a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py +++ b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py @@ -140,7 +140,7 @@ def get_gl_filters(doc, entry, tax_id, presentation_currency): def get_ar_filters(doc, entry): return { "report_date": doc.posting_date if doc.posting_date else None, - "customer_name": entry.customer, + "customer": entry.customer, "payment_terms_template": doc.payment_terms_template if doc.payment_terms_template else None, "sales_partner": doc.sales_partner if doc.sales_partner else None, "sales_person": doc.sales_person if doc.sales_person else None, diff --git a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts_accounts_receivable.html b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts_accounts_receivable.html index 07e1896292..259526f8c4 100644 --- a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts_accounts_receivable.html +++ b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts_accounts_receivable.html @@ -10,16 +10,12 @@

{{ _(report.report_name) }}

- {% if (filters.customer_name) %} - {{ filters.customer_name }} - {% else %} - {{ filters.customer ~ filters.supplier }} - {% endif %} + {{ filters.customer }}

- {% if (filters.tax_id) %} - {{ _("Tax Id: ") }}{{ filters.tax_id }} - {% endif %} + {% if (filters.tax_id) %} + {{ _("Tax Id: ") }}{{ filters.tax_id }} + {% endif %}
{{ _(filters.ageing_based_on) }} diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js index 4ec103c9f2..3a0a2cf8ce 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js @@ -897,6 +897,8 @@ frappe.ui.form.on('Sales Invoice', { frm.events.append_time_log(frm, timesheet, 1.0); } }); + frm.refresh_field("timesheets"); + frm.trigger("calculate_timesheet_totals"); }, async get_exchange_rate(frm, from_currency, to_currency) { @@ -936,9 +938,6 @@ frappe.ui.form.on('Sales Invoice', { row.billing_amount = flt(time_log.billing_amount) * flt(exchange_rate); row.timesheet_detail = time_log.name; row.project_name = time_log.project_name; - - frm.refresh_field("timesheets"); - frm.trigger("calculate_timesheet_totals"); }, calculate_timesheet_totals: function(frm) { diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py index 03cf82a2b0..4996203635 100644 --- a/erpnext/accounts/party.py +++ b/erpnext/accounts/party.py @@ -33,6 +33,7 @@ import erpnext from erpnext import get_company_currency from erpnext.accounts.utils import get_fiscal_year from erpnext.exceptions import InvalidAccountCurrency, PartyDisabled, PartyFrozen +from erpnext.utilities.regional import temporary_flag PURCHASE_TRANSACTION_TYPES = {"Purchase Order", "Purchase Receipt", "Purchase Invoice"} SALES_TRANSACTION_TYPES = { @@ -261,9 +262,8 @@ def set_address_details( ) if doctype in TRANSACTION_TYPES: - # required to set correct region - frappe.flags.company = company - get_regional_address_details(party_details, doctype, company) + with temporary_flag("company", company): + get_regional_address_details(party_details, doctype, company) return party_address, shipping_address diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py index 42f531189a..7dc438e08f 100644 --- a/erpnext/assets/doctype/asset/asset.py +++ b/erpnext/assets/doctype/asset/asset.py @@ -933,6 +933,8 @@ def create_new_asset_after_split(asset, split_qty): ) new_asset.gross_purchase_amount = new_gross_purchase_amount + if asset.purchase_receipt_amount: + new_asset.purchase_receipt_amount = new_gross_purchase_amount new_asset.opening_accumulated_depreciation = opening_accumulated_depreciation new_asset.asset_quantity = split_qty new_asset.split_from = asset.name diff --git a/erpnext/assets/doctype/asset_movement/asset_movement.py b/erpnext/assets/doctype/asset_movement/asset_movement.py index 22055dcb73..b85f7194f9 100644 --- a/erpnext/assets/doctype/asset_movement/asset_movement.py +++ b/erpnext/assets/doctype/asset_movement/asset_movement.py @@ -62,20 +62,21 @@ class AssetMovement(Document): frappe.throw(_("Source and Target Location cannot be same")) if self.purpose == "Receipt": - if not (d.source_location or d.from_employee) and not (d.target_location or d.to_employee): + if not (d.source_location) and not (d.target_location or d.to_employee): frappe.throw( _("Target Location or To Employee is required while receiving Asset {0}").format(d.asset) ) - elif d.from_employee and not d.target_location: - frappe.throw( - _("Target Location is required while receiving Asset {0} from an employee").format(d.asset) - ) - elif d.to_employee and d.target_location: - frappe.throw( - _( - "Asset {0} cannot be received at a location and given to an employee in a single movement" - ).format(d.asset) - ) + elif d.source_location: + if d.from_employee and not d.target_location: + frappe.throw( + _("Target Location is required while receiving Asset {0} from an employee").format(d.asset) + ) + elif d.to_employee and d.target_location: + frappe.throw( + _( + "Asset {0} cannot be received at a location and given to an employee in a single movement" + ).format(d.asset) + ) def validate_employee(self): for d in self.assets: diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 4193b5327d..79404894cd 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -56,6 +56,7 @@ from erpnext.stock.get_item_details import ( get_item_tax_map, get_item_warehouse, ) +from erpnext.utilities.regional import temporary_flag from erpnext.utilities.transaction_base import TransactionBase @@ -760,7 +761,9 @@ class AccountsController(TransactionBase): } ) - update_gl_dict_with_regional_fields(self, gl_dict) + with temporary_flag("company", self.company): + update_gl_dict_with_regional_fields(self, gl_dict) + accounting_dimensions = get_accounting_dimensions() dimension_dict = frappe._dict() diff --git a/erpnext/controllers/queries.py b/erpnext/controllers/queries.py index d1dcd6a109..5ec24743d9 100644 --- a/erpnext/controllers/queries.py +++ b/erpnext/controllers/queries.py @@ -874,3 +874,18 @@ def get_fields(doctype, fields=None): fields.insert(1, meta.title_field.strip()) return unique(fields) + + +@frappe.whitelist() +@frappe.validate_and_sanitize_search_inputs +def get_payment_terms_for_references(doctype, txt, searchfield, start, page_len, filters) -> list: + terms = [] + if filters: + terms = frappe.db.get_all( + "Payment Schedule", + filters={"parent": filters.get("reference")}, + fields=["payment_term"], + limit=page_len, + as_list=1, + ) + return terms diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index fac713de3e..62d4c53868 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -18,6 +18,7 @@ from erpnext.controllers.accounts_controller import ( validate_taxes_and_charges, ) from erpnext.stock.get_item_details import _get_item_tax_template +from erpnext.utilities.regional import temporary_flag class calculate_taxes_and_totals(object): @@ -942,7 +943,6 @@ class calculate_taxes_and_totals(object): def get_itemised_tax_breakup_html(doc): if not doc.taxes: return - frappe.flags.company = doc.company # get headers tax_accounts = [] @@ -952,15 +952,11 @@ def get_itemised_tax_breakup_html(doc): if tax.description not in tax_accounts: tax_accounts.append(tax.description) - headers = get_itemised_tax_breakup_header(doc.doctype + " Item", tax_accounts) - - # get tax breakup data - itemised_tax_data = get_itemised_tax_breakup_data(doc) - - get_rounded_tax_amount(itemised_tax_data, doc.precision("tax_amount", "taxes")) - - update_itemised_tax_data(doc) - frappe.flags.company = None + with temporary_flag("company", doc.company): + headers = get_itemised_tax_breakup_header(doc.doctype + " Item", tax_accounts) + itemised_tax_data = get_itemised_tax_breakup_data(doc) + get_rounded_tax_amount(itemised_tax_data, doc.precision("tax_amount", "taxes")) + update_itemised_tax_data(doc) return frappe.render_template( "templates/includes/itemised_tax_breakup.html", @@ -976,10 +972,8 @@ def get_itemised_tax_breakup_html(doc): @frappe.whitelist() def get_round_off_applicable_accounts(company, account_list): # required to set correct region - frappe.flags.company = company - account_list = get_regional_round_off_accounts(company, account_list) - - return account_list + with temporary_flag("company", company): + return get_regional_round_off_accounts(company, account_list) @erpnext.allow_regional diff --git a/erpnext/patches/v14_0/single_to_multi_dunning.py b/erpnext/patches/v14_0/single_to_multi_dunning.py index 7a8e591798..3b01871d43 100644 --- a/erpnext/patches/v14_0/single_to_multi_dunning.py +++ b/erpnext/patches/v14_0/single_to_multi_dunning.py @@ -7,7 +7,16 @@ def execute(): frappe.reload_doc("accounts", "doctype", "overdue_payment") frappe.reload_doc("accounts", "doctype", "dunning") - all_dunnings = frappe.get_all("Dunning", filters={"docstatus": ("!=", 2)}, pluck="name") + # Migrate schema of all uncancelled dunnings + filters = {"docstatus": ("!=", 2)} + + can_edit_accounts_after = get_accounts_closing_date() + if can_edit_accounts_after: + # Get dunnings after the date when accounts were frozen/closed + filters["posting_date"] = (">", can_edit_accounts_after) + + all_dunnings = frappe.get_all("Dunning", filters=filters, pluck="name") + for dunning_name in all_dunnings: dunning = frappe.get_doc("Dunning", dunning_name) if not dunning.sales_invoice: @@ -41,9 +50,29 @@ def execute(): dunning.flags.ignore_validate_update_after_submit = True dunning.save() - if dunning.status != "Resolved": + # Reverse entries only if dunning is submitted and not resolved + if dunning.docstatus == 1 and dunning.status != "Resolved": # With the new logic, dunning amount gets recorded as additional income # at time of payment. We don't want to record the dunning amount twice, # so we reverse previous GL Entries that recorded the dunning amount at # time of submission of the Dunning. make_reverse_gl_entries(voucher_type="Dunning", voucher_no=dunning.name) + + +def get_accounts_closing_date(): + """Get the date when accounts were frozen/closed""" + accounts_frozen_till = frappe.db.get_single_value( + "Accounts Settings", "acc_frozen_upto" + ) # always returns datetime.date + + period_closing_date = frappe.db.get_value( + "Period Closing Voucher", {"docstatus": 1}, "posting_date", order_by="posting_date desc" + ) + + # Set most recent frozen/closing date as filter + if accounts_frozen_till and period_closing_date: + can_edit_accounts_after = max(accounts_frozen_till, period_closing_date) + else: + can_edit_accounts_after = accounts_frozen_till or period_closing_date + + return can_edit_accounts_after diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py index 27066b825c..f128c8e206 100644 --- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py @@ -271,7 +271,11 @@ def repost(doc): message += "
" + "Traceback:
" + traceback frappe.db.set_value(doc.doctype, doc.name, "error_log", message) - if not isinstance(e, RecoverableErrors): + outgoing_email_account = frappe.get_cached_value( + "Email Account", {"default_outgoing": 1, "enable_outgoing": 1}, "name" + ) + + if outgoing_email_account and not isinstance(e, RecoverableErrors): notify_error_to_stock_managers(doc, message) doc.set_status("Failed") finally: diff --git a/erpnext/utilities/regional.py b/erpnext/utilities/regional.py new file mode 100644 index 0000000000..858976f855 --- /dev/null +++ b/erpnext/utilities/regional.py @@ -0,0 +1,13 @@ +from contextlib import contextmanager + +import frappe + + +@contextmanager +def temporary_flag(flag_name, value): + flags = frappe.local.flags + flags[flag_name] = value + try: + yield + finally: + flags.pop(flag_name, None)