diff --git a/erpnext/accounts/doctype/payment_schedule/payment_schedule.json b/erpnext/accounts/doctype/payment_schedule/payment_schedule.json index b72281b6314..2b3e396a052 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", @@ -172,12 +177,50 @@ "label": "Paid Amount (Company Currency)", "options": "Company:company:default_currency", "read_only": 1 + }, + { + "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": "2025-03-11 11:06:51.792982", + "modified": "2025-07-31 08:38:25.820701", "modified_by": "Administrator", "module": "Accounts", "name": "Payment Schedule", @@ -189,4 +232,4 @@ "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/accounts/doctype/payment_schedule/payment_schedule.py b/erpnext/accounts/doctype/payment_schedule/payment_schedule.py index a3d1dbe5564..f506be0eb27 100644 --- a/erpnext/accounts/doctype/payment_schedule/payment_schedule.py +++ b/erpnext/accounts/doctype/payment_schedule/payment_schedule.py @@ -17,12 +17,27 @@ class PaymentSchedule(Document): base_outstanding: DF.Currency base_paid_amount: DF.Currency base_payment_amount: DF.Currency + credit_days: DF.Int + credit_months: DF.Int description: DF.SmallText | None discount: DF.Float discount_date: DF.Date | None discount_type: DF.Literal["Percentage", "Amount"] + discount_validity: DF.Int + discount_validity_based_on: DF.Literal[ + "", + "Day(s) after invoice date", + "Day(s) after the end of the invoice month", + "Month(s) after the end of the invoice month", + ] discounted_amount: DF.Currency due_date: DF.Date + due_date_based_on: DF.Literal[ + "", + "Day(s) after invoice date", + "Day(s) after the end of the invoice month", + "Month(s) after the end of the invoice month", + ] invoice_portion: DF.Percent mode_of_payment: DF.Link | None outstanding: DF.Currency 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 8d5e0f910c3..3bb5aa80157 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 @@ -162,4 +162,4 @@ "sort_order": "DESC", "states": [], "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 ff6f0249660..280d338d56d 100644 --- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py @@ -2147,19 +2147,16 @@ class TestPurchaseInvoice(IntegrationTestCase, StockTestMixin): rate = flt(sle.stock_value_difference) / flt(sle.actual_qty) self.assertAlmostEqual(rate, 500) + @IntegrationTestCase.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", @@ -2185,7 +2182,6 @@ class TestPurchaseInvoice(IntegrationTestCase, 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 6a1dd34d83b..6da863cb040 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -540,12 +540,8 @@ class TestPurchaseOrder(IntegrationTestCase): self.assertRaises(frappe.ValidationError, pr.submit) self.assertRaises(frappe.ValidationError, pi.submit) + @IntegrationTestCase.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) @@ -569,7 +565,6 @@ class TestPurchaseOrder(IntegrationTestCase): 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 @@ -717,6 +712,7 @@ class TestPurchaseOrder(IntegrationTestCase): ) self.assertEqual(due_date, "2023-03-31") + @IntegrationTestCase.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" @@ -910,18 +906,16 @@ class TestPurchaseOrder(IntegrationTestCase): bo.load_from_db() self.assertEqual(bo.items[0].ordered_qty, 5) + @IntegrationTestCase.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" @@ -935,8 +929,6 @@ class TestPurchaseOrder(IntegrationTestCase): # 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.cost_center.test_cost_center import create_cost_center from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 7ce9dc89146..883f6595c6a 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -2570,6 +2570,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 = { @@ -2582,6 +2583,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"), @@ -3384,14 +3396,27 @@ 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 + term_details.base_outstanding = term_details.base_payment_amount if bill_date: term_details.due_date = get_due_date(term, bill_date) @@ -3410,11 +3435,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 @@ -3422,11 +3447,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 844a768f4f7..5008b4707d8 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -1229,12 +1229,25 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe } } - due_date(doc, cdt) { + 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(doc, cdt, cdn) { // 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 if (doc.doctype !== cdt) { - // triggered by change to the due_date field in payment schedule child table - // do nothing to avoid infinite clearing loop + // Remove fields as due_date is auto-managed by payment terms + const row = locals[cdt][cdn]; + ["due_date_based_on", "credit_days", "credit_months"].forEach((field) => { + row[field] = ""; + }); + this.frm.refresh_field("payment_schedule"); return; } @@ -2990,6 +3003,17 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe payment_term(doc, cdt, cdn) { const me = this; var row = locals[cdt][cdn]; + // 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", @@ -3002,14 +3026,17 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe }, 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); + 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 f3892b8090b..7aba6c3475b 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -11,7 +11,7 @@ from frappe.tests import IntegrationTestCase, 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 InvalidQtyError, update_child_qty_rate +from erpnext.controllers.accounts_controller import InvalidQtyError, get_due_date, update_child_qty_rate from erpnext.maintenance.doctype.maintenance_schedule.test_maintenance_schedule import ( make_maintenance_schedule, ) @@ -1678,14 +1678,13 @@ class TestSalesOrder(AccountsTestMixin, IntegrationTestCase): so.load_from_db() self.assertRaises(frappe.LinkExistsError, so.cancel) + @IntegrationTestCase.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" @@ -1699,8 +1698,6 @@ class TestSalesOrder(AccountsTestMixin, IntegrationTestCase): 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 @@ -2593,16 +2590,14 @@ class TestSalesOrder(AccountsTestMixin, IntegrationTestCase): self.assertEqual(si2.items[0].qty, 20) -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 c872bc92997..61ec1dd48d4 100644 --- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py @@ -15,7 +15,6 @@ from erpnext.accounts.utils import get_balance_on from erpnext.controllers.accounts_controller import InvalidQtyError 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, @@ -1316,14 +1315,13 @@ class TestDeliveryNote(IntegrationTestCase): frappe.db.set_single_value("Stock Settings", "use_serial_batch_fields", 1) frappe.db.set_single_value("Accounts Settings", "delete_linked_ledger_entries", 0) + @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" @@ -1343,8 +1341,6 @@ class TestDeliveryNote(IntegrationTestCase): 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 539f538e775..5e8da9e7669 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -1201,6 +1201,7 @@ class TestPurchaseReceipt(IntegrationTestCase): self.assertEqual(discrepancy_caused_by_exchange_rate_diff, amount) + @IntegrationTestCase.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, @@ -1211,12 +1212,9 @@ class TestPurchaseReceipt(IntegrationTestCase): 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" @@ -1234,8 +1232,6 @@ class TestPurchaseReceipt(IntegrationTestCase): # self.assertEqual(po.payment_terms_template, pi.payment_terms_template) compare_payment_schedules(self, po, pi) - automatically_fetch_payment_terms(enable=0) - @IntegrationTestCase.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