From d82106cb76122a1f3dedba88a30c0ff111a1ef0c Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 7 Oct 2025 14:10:46 +0530 Subject: [PATCH] feat: dynamic due date in payment terms when fetched from order (backport #48864) (#49937) * feat: dynamic due date in payment terms when fetched from order (#48864) * fix: dynamic due date when payment terms are fetched from order * fix(test): use change_settings decorator for settings enable and disable * fix(test): compare schedule for due_date dynamically * fix: save conditions for due date at invoice level * fix: make fields read only and on change of date unset the date condition fields * fix: remove fetch_form * fix: correct field assingment * fix: revert unwanted changes * refactor: streamline payment term field assignments and enhance discount date handling * refactor: remove payment_term from fields_to_copy and optimize currency handling in transaction callback * refactor: ensure default values for payment schedule and discount validity fields (cherry picked from commit 3c70cbbaf8929b358008c2bab6e0c799466ea8b7) # Conflicts: # erpnext/accounts/doctype/payment_schedule/payment_schedule.json # erpnext/accounts/doctype/payment_schedule/payment_schedule.py # erpnext/public/js/controllers/transaction.js # erpnext/selling/doctype/sales_order/test_sales_order.py # erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py * chore: resolve conflicts --------- Co-authored-by: Lakshit Jain --- .../payment_schedule/payment_schedule.json | 53 +++++++++++++++++-- .../payment_terms_template_detail.json | 2 +- .../purchase_invoice/test_purchase_invoice.py | 6 +-- .../purchase_order/test_purchase_order.py | 14 ++--- erpnext/controllers/accounts_controller.py | 46 ++++++++++++---- erpnext/public/js/controllers/transaction.js | 41 ++++++++++---- .../doctype/sales_order/test_sales_order.py | 19 +++---- .../delivery_note/test_delivery_note.py | 6 +-- .../purchase_receipt/test_purchase_receipt.py | 6 +-- 9 files changed, 129 insertions(+), 64 deletions(-) diff --git a/erpnext/accounts/doctype/payment_schedule/payment_schedule.json b/erpnext/accounts/doctype/payment_schedule/payment_schedule.json index dde9980ce53..2572d4c8153 100644 --- a/erpnext/accounts/doctype/payment_schedule/payment_schedule.json +++ b/erpnext/accounts/doctype/payment_schedule/payment_schedule.json @@ -10,14 +10,19 @@ "description", "section_break_4", "due_date", + "invoice_portion", "mode_of_payment", "column_break_5", - "invoice_portion", + "due_date_based_on", + "credit_days", + "credit_months", "section_break_6", - "discount_type", "discount_date", - "column_break_9", "discount", + "discount_type", + "column_break_9", + "discount_validity_based_on", + "discount_validity", "section_break_9", "payment_amount", "outstanding", @@ -155,12 +160,50 @@ "fieldtype": "Currency", "label": "Payment Amount (Company Currency)", "options": "Company:company:default_currency" + }, + { + "fieldname": "due_date_based_on", + "fieldtype": "Select", + "label": "Due Date Based On", + "options": "\nDay(s) after invoice date\nDay(s) after the end of the invoice month\nMonth(s) after the end of the invoice month", + "read_only": 1 + }, + { + "depends_on": "eval:in_list(['Day(s) after invoice date', 'Day(s) after the end of the invoice month'], doc.due_date_based_on)", + "fieldname": "credit_days", + "fieldtype": "Int", + "label": "Credit Days", + "non_negative": 1, + "read_only": 1 + }, + { + "depends_on": "eval:doc.due_date_based_on=='Month(s) after the end of the invoice month'", + "fieldname": "credit_months", + "fieldtype": "Int", + "label": "Credit Months", + "non_negative": 1, + "read_only": 1 + }, + { + "depends_on": "discount", + "fieldname": "discount_validity_based_on", + "fieldtype": "Select", + "label": "Discount Validity Based On", + "options": "\nDay(s) after invoice date\nDay(s) after the end of the invoice month\nMonth(s) after the end of the invoice month", + "read_only": 1 + }, + { + "depends_on": "discount_validity_based_on", + "fieldname": "discount_validity", + "fieldtype": "Int", + "label": "Discount Validity", + "read_only": 1 } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2022-09-16 13:57:06.382859", + "modified": "2025-07-31 08:38:25.820701", "modified_by": "Administrator", "module": "Accounts", "name": "Payment Schedule", @@ -171,4 +214,4 @@ "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/accounts/doctype/payment_terms_template_detail/payment_terms_template_detail.json b/erpnext/accounts/doctype/payment_terms_template_detail/payment_terms_template_detail.json index 20b3dca6aae..fca9eaa53c2 100644 --- a/erpnext/accounts/doctype/payment_terms_template_detail/payment_terms_template_detail.json +++ b/erpnext/accounts/doctype/payment_terms_template_detail/payment_terms_template_detail.json @@ -161,4 +161,4 @@ "sort_field": "modified", "sort_order": "DESC", "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py index 7d52fdcff25..fa01e452e90 100644 --- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py @@ -1816,19 +1816,16 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin): rate = flt(sle.stock_value_difference) / flt(sle.actual_qty) self.assertAlmostEqual(rate, 500) + @change_settings("Accounts Settings", {"automatically_fetch_payment_terms": 1}) def test_payment_allocation_for_payment_terms(self): from erpnext.buying.doctype.purchase_order.test_purchase_order import ( create_pr_against_po, create_purchase_order, ) - from erpnext.selling.doctype.sales_order.test_sales_order import ( - automatically_fetch_payment_terms, - ) from erpnext.stock.doctype.purchase_receipt.purchase_receipt import ( make_purchase_invoice as make_pi_from_pr, ) - automatically_fetch_payment_terms() frappe.db.set_value( "Payment Terms Template", "_Test Payment Term Template", @@ -1854,7 +1851,6 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin): pi = make_pi_from_pr(pr.name) self.assertEqual(pi.payment_schedule[0].payment_amount, 1000) - automatically_fetch_payment_terms(enable=0) frappe.db.set_value( "Payment Terms Template", "_Test Payment Term Template", diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index 38cb0fbf7ab..d54ad8d33c7 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -526,12 +526,8 @@ class TestPurchaseOrder(FrappeTestCase): self.assertRaises(frappe.ValidationError, pr.submit) self.assertRaises(frappe.ValidationError, pi.submit) + @change_settings("Accounts Settings", {"automatically_fetch_payment_terms": 1}) def test_make_purchase_invoice_with_terms(self): - from erpnext.selling.doctype.sales_order.test_sales_order import ( - automatically_fetch_payment_terms, - ) - - automatically_fetch_payment_terms() po = create_purchase_order(do_not_save=True) self.assertRaises(frappe.ValidationError, make_pi_from_po, po.name) @@ -555,7 +551,6 @@ class TestPurchaseOrder(FrappeTestCase): self.assertEqual(getdate(pi.payment_schedule[0].due_date), getdate(po.transaction_date)) self.assertEqual(pi.payment_schedule[1].payment_amount, 2500.0) self.assertEqual(getdate(pi.payment_schedule[1].due_date), add_days(getdate(po.transaction_date), 30)) - automatically_fetch_payment_terms(enable=0) def test_warehouse_company_validation(self): from erpnext.stock.utils import InvalidWarehouseCompany @@ -703,6 +698,7 @@ class TestPurchaseOrder(FrappeTestCase): ) self.assertEqual(due_date, "2023-03-31") + @change_settings("Accounts Settings", {"automatically_fetch_payment_terms": 0}) def test_terms_are_not_copied_if_automatically_fetch_payment_terms_is_unchecked(self): po = create_purchase_order(do_not_save=1) po.payment_terms_template = "_Test Payment Term Template" @@ -834,18 +830,16 @@ class TestPurchaseOrder(FrappeTestCase): bo.load_from_db() self.assertEqual(bo.items[0].ordered_qty, 5) + @change_settings("Accounts Settings", {"automatically_fetch_payment_terms": 1}) def test_payment_terms_are_fetched_when_creating_purchase_invoice(self): from erpnext.accounts.doctype.payment_entry.test_payment_entry import ( create_payment_terms_template, ) from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice from erpnext.selling.doctype.sales_order.test_sales_order import ( - automatically_fetch_payment_terms, compare_payment_schedules, ) - automatically_fetch_payment_terms() - po = create_purchase_order(qty=10, rate=100, do_not_save=1) create_payment_terms_template() po.payment_terms_template = "Test Receivable Template" @@ -859,8 +853,6 @@ class TestPurchaseOrder(FrappeTestCase): # self.assertEqual(po.payment_terms_template, pi.payment_terms_template) compare_payment_schedules(self, po, pi) - automatically_fetch_payment_terms(enable=0) - def test_internal_transfer_flow(self): from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( make_inter_company_purchase_invoice, diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 756fa5f6d09..77d4db8504f 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -2239,6 +2239,7 @@ class AccountsController(TransactionBase): self.payment_schedule = [] self.payment_terms_template = po_or_so.payment_terms_template + posting_date = self.get("bill_date") or self.get("posting_date") or self.get("transaction_date") for schedule in po_or_so.payment_schedule: payment_schedule = { @@ -2251,6 +2252,17 @@ class AccountsController(TransactionBase): } if automatically_fetch_payment_terms: + if schedule.due_date_based_on: + payment_schedule["due_date"] = get_due_date(schedule, posting_date) + payment_schedule["due_date_based_on"] = schedule.due_date_based_on + payment_schedule["credit_days"] = cint(schedule.credit_days) + payment_schedule["credit_months"] = cint(schedule.credit_months) + + if schedule.discount_validity_based_on: + payment_schedule["discount_date"] = get_discount_date(schedule, posting_date) + payment_schedule["discount_validity_based_on"] = schedule.discount_validity_based_on + payment_schedule["discount_validity"] = cint(schedule.discount_validity) + payment_schedule["payment_amount"] = flt( grand_total * flt(payment_schedule["invoice_portion"]) / 100, schedule.precision("payment_amount"), @@ -2987,14 +2999,26 @@ def get_payment_term_details( term = frappe.get_doc("Payment Term", term) else: term_details.payment_term = term.payment_term - term_details.description = term.description - term_details.invoice_portion = term.invoice_portion + + fields_to_copy = [ + "description", + "invoice_portion", + "discount_type", + "discount", + "mode_of_payment", + "due_date_based_on", + "credit_days", + "credit_months", + "discount_validity_based_on", + "discount_validity", + ] + + for field in fields_to_copy: + term_details[field] = term.get(field) + term_details.payment_amount = flt(term.invoice_portion) * flt(grand_total) / 100 term_details.base_payment_amount = flt(term.invoice_portion) * flt(base_grand_total) / 100 - term_details.discount_type = term.discount_type - term_details.discount = term.discount term_details.outstanding = term_details.payment_amount - term_details.mode_of_payment = term.mode_of_payment if bill_date: term_details.due_date = get_due_date(term, bill_date) @@ -3013,11 +3037,11 @@ def get_due_date(term, posting_date=None, bill_date=None): due_date = None date = bill_date or posting_date if term.due_date_based_on == "Day(s) after invoice date": - due_date = add_days(date, term.credit_days) + due_date = add_days(date, cint(term.credit_days)) elif term.due_date_based_on == "Day(s) after the end of the invoice month": - due_date = add_days(get_last_day(date), term.credit_days) + due_date = add_days(get_last_day(date), cint(term.credit_days)) elif term.due_date_based_on == "Month(s) after the end of the invoice month": - due_date = get_last_day(add_months(date, term.credit_months)) + due_date = get_last_day(add_months(date, cint(term.credit_months))) return due_date @@ -3025,11 +3049,11 @@ def get_discount_date(term, posting_date=None, bill_date=None): discount_validity = None date = bill_date or posting_date if term.discount_validity_based_on == "Day(s) after invoice date": - discount_validity = add_days(date, term.discount_validity) + discount_validity = add_days(date, cint(term.discount_validity)) elif term.discount_validity_based_on == "Day(s) after the end of the invoice month": - discount_validity = add_days(get_last_day(date), term.discount_validity) + discount_validity = add_days(get_last_day(date), cint(term.discount_validity)) elif term.discount_validity_based_on == "Month(s) after the end of the invoice month": - discount_validity = get_last_day(add_months(date, term.discount_validity)) + discount_validity = get_last_day(add_months(date, cint(term.discount_validity))) return discount_validity diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index 5fa63cb9c02..a37b4048e10 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -878,6 +878,15 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe } } + discount_date(doc, cdt, cdn) { + // Remove fields as discount_date is auto-managed by payment terms + const row = locals[cdt][cdn]; + ["discount_validity", "discount_validity_based_on"].forEach((field) => { + row[field] = ""; + }); + this.frm.refresh_field("payment_schedule"); + } + due_date() { // due_date is to be changed, payment terms template and/or payment schedule must // be removed as due_date is automatically changed based on payment terms @@ -2245,7 +2254,18 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe payment_term(doc, cdt, cdn) { const me = this; var row = locals[cdt][cdn]; - if(row.payment_term) { + // empty date condition fields + [ + "due_date_based_on", + "credit_days", + "credit_months", + "discount_validity", + "discount_validity_based_on", + ].forEach(function (field) { + row[field] = ""; + }); + + if (row.payment_term) { frappe.call({ method: "erpnext.controllers.accounts_controller.get_payment_term_details", args: { @@ -2255,16 +2275,19 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe grand_total: this.frm.doc.rounded_total || this.frm.doc.grand_total, base_grand_total: this.frm.doc.base_rounded_total || this.frm.doc.base_grand_total }, - callback: function(r) { - if(r.message && !r.exc) { - for (var d in r.message) { - frappe.model.set_value(cdt, cdn, d, r.message[d]); - const company_currency = me.get_company_currency(); - me.update_payment_schedule_grid_labels(company_currency); + callback: function (r) { + if (r.message && !r.exc) { + const company_currency = me.get_company_currency(); + for (let d in r.message) { + row[d] = r.message[d]; } + me.update_payment_schedule_grid_labels(company_currency); + me.frm.refresh_field("payment_schedule"); } - } - }) + }, + }); + } else { + me.frm.refresh_field("payment_schedule"); } } diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 2f795cae5c4..5a71dac8200 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -10,7 +10,7 @@ from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import add_days, flt, getdate, nowdate, today from erpnext.accounts.test.accounts_mixin import AccountsTestMixin -from erpnext.controllers.accounts_controller import update_child_qty_rate +from erpnext.controllers.accounts_controller import get_due_date, update_child_qty_rate from erpnext.maintenance.doctype.maintenance_schedule.test_maintenance_schedule import ( make_maintenance_schedule, ) @@ -1759,14 +1759,13 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): so.load_from_db() self.assertRaises(frappe.LinkExistsError, so.cancel) + @change_settings("Accounts Settings", {"automatically_fetch_payment_terms": 1}) def test_payment_terms_are_fetched_when_creating_sales_invoice(self): from erpnext.accounts.doctype.payment_entry.test_payment_entry import ( create_payment_terms_template, ) from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice - automatically_fetch_payment_terms() - so = make_sales_order(uom="Nos", do_not_save=1) create_payment_terms_template() so.payment_terms_template = "Test Receivable Template" @@ -1780,8 +1779,6 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): self.assertEqual(so.payment_terms_template, si.payment_terms_template) compare_payment_schedules(self, so, si) - automatically_fetch_payment_terms(enable=0) - def test_zero_amount_sales_order_billing_status(self): from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice @@ -2284,16 +2281,14 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): self.assertRaises(frappe.ValidationError, so1.update_status, "Draft") -def automatically_fetch_payment_terms(enable=1): - accounts_settings = frappe.get_doc("Accounts Settings") - accounts_settings.automatically_fetch_payment_terms = enable - accounts_settings.save() - - def compare_payment_schedules(doc, doc1, doc2): for index, schedule in enumerate(doc1.get("payment_schedule")): + posting_date = doc1.get("bill_date") or doc1.get("posting_date") or doc1.get("transaction_date") + due_date = schedule.due_date + if schedule.due_date_based_on: + due_date = get_due_date(schedule, posting_date=posting_date) doc.assertEqual(schedule.payment_term, doc2.payment_schedule[index].payment_term) - doc.assertEqual(getdate(schedule.due_date), doc2.payment_schedule[index].due_date) + doc.assertEqual(due_date, doc2.payment_schedule[index].due_date) doc.assertEqual(schedule.invoice_portion, doc2.payment_schedule[index].invoice_portion) doc.assertEqual(schedule.payment_amount, doc2.payment_schedule[index].payment_amount) diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py index 1dfa4c0a10d..507fb78663e 100644 --- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py @@ -13,7 +13,6 @@ from erpnext.accounts.utils import get_balance_on from erpnext.controllers.sales_and_purchase_return import make_return_doc from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle from erpnext.selling.doctype.sales_order.test_sales_order import ( - automatically_fetch_payment_terms, compare_payment_schedules, create_dn_against_so, make_sales_order, @@ -1026,14 +1025,13 @@ class TestDeliveryNote(FrappeTestCase): self.assertTrue("TESTBATCH" in dn.packed_items[0].batch_no, "Batch number not added in packed item") + @change_settings("Accounts Settings", {"automatically_fetch_payment_terms": 1}) def test_payment_terms_are_fetched_when_creating_sales_invoice(self): from erpnext.accounts.doctype.payment_entry.test_payment_entry import ( create_payment_terms_template, ) from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice - automatically_fetch_payment_terms() - so = make_sales_order(uom="Nos", do_not_save=1) create_payment_terms_template() so.payment_terms_template = "Test Receivable Template" @@ -1053,8 +1051,6 @@ class TestDeliveryNote(FrappeTestCase): self.assertEqual(so.payment_terms_template, si.payment_terms_template) compare_payment_schedules(self, so, si) - automatically_fetch_payment_terms(enable=0) - def test_returned_qty_in_return_dn(self): # SO ---> SI ---> DN # | diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index 199a44b3734..97ce8c16d43 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -1056,6 +1056,7 @@ class TestPurchaseReceipt(FrappeTestCase): self.assertEqual(discrepancy_caused_by_exchange_rate_diff, amount) + @change_settings("Accounts Settings", {"automatically_fetch_payment_terms": 1}) def test_payment_terms_are_fetched_when_creating_purchase_invoice(self): from erpnext.accounts.doctype.payment_entry.test_payment_entry import ( create_payment_terms_template, @@ -1066,12 +1067,9 @@ class TestPurchaseReceipt(FrappeTestCase): make_pr_against_po, ) from erpnext.selling.doctype.sales_order.test_sales_order import ( - automatically_fetch_payment_terms, compare_payment_schedules, ) - automatically_fetch_payment_terms() - po = create_purchase_order(qty=10, rate=100, do_not_save=1) create_payment_terms_template() po.payment_terms_template = "Test Receivable Template" @@ -1089,8 +1087,6 @@ class TestPurchaseReceipt(FrappeTestCase): # self.assertEqual(po.payment_terms_template, pi.payment_terms_template) compare_payment_schedules(self, po, pi) - automatically_fetch_payment_terms(enable=0) - @change_settings("Stock Settings", {"allow_negative_stock": 1}) def test_neg_to_positive(self): from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry