diff --git a/erpnext/accounts/services/billing_validation.py b/erpnext/accounts/services/billing_validation.py new file mode 100644 index 00000000000..a40a885f283 --- /dev/null +++ b/erpnext/accounts/services/billing_validation.py @@ -0,0 +1,147 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Billing amount validation helpers (overbilling checks).""" + +import frappe +from frappe import _ +from frappe.query_builder.functions import Sum +from frappe.utils import cint, flt, fmt_money + + +def validate_multiple_billing(doc, ref_dt: str, item_ref_dn: str, based_on: str) -> None: + from erpnext.controllers.status_updater import get_allowance_for + + ref_wise_billed_amount = get_reference_wise_billed_amt(doc, ref_dt, item_ref_dn, based_on) + if not ref_wise_billed_amount: + return + + total_overbilled_amt = 0.0 + overbilled_items = [] + precision = doc.precision(based_on, "items") + precision_allowance = 1 / (10**precision) + + role_allowed_to_overbill = frappe.get_single_value("Accounts Settings", "role_allowed_to_over_bill") + is_overbilling_allowed = role_allowed_to_overbill in frappe.get_roles() + + for row in ref_wise_billed_amount.values(): + total_billed_amt = row.billed_amt + allowance = get_allowance_for(row.item_code, {}, None, None, "amount")[0] + max_allowed_amt = flt(row.ref_amt * (100 + allowance) / 100) + + if total_billed_amt < 0 and max_allowed_amt < 0: + total_billed_amt, max_allowed_amt = abs(total_billed_amt), abs(max_allowed_amt) + + overbill_amt = total_billed_amt - max_allowed_amt + row["max_allowed_amt"] = max_allowed_amt + total_overbilled_amt += overbill_amt + + if overbill_amt > precision_allowance and not is_overbilling_allowed: + if doc.doctype != "Purchase Invoice" or not cint( + frappe.db.get_single_value( + "Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice" + ) + ): + overbilled_items.append(row) + + if overbilled_items: + throw_overbill_exception(doc, overbilled_items, precision) + + if is_overbilling_allowed and total_overbilled_amt > 0.1: + frappe.msgprint( + _("Overbilling of {} ignored because you have {} role.").format( + total_overbilled_amt, role_allowed_to_overbill + ), + indicator="orange", + alert=True, + ) + + +def get_reference_wise_billed_amt(doc, ref_dt: str, item_ref_dn: str, based_on: str) -> dict | None: + """Return sum of billed amounts per reference row, including previously submitted invoices.""" + reference_names = [d.get(item_ref_dn) for d in doc.items if d.get(item_ref_dn)] + if not reference_names: + return + + precision = doc.precision(based_on, "items") + reference_details = get_billing_reference_details(doc, reference_names, ref_dt + " Item", based_on) + already_billed = get_already_billed_amount(doc, reference_names, item_ref_dn, based_on) + + ref_wise_billed_amount = {} + for item in doc.items: + key = item.get(item_ref_dn) + if not key: + continue + + ref_amt = flt(reference_details.get(key), precision) + current_amount = flt(item.get(based_on), precision) + + if not ref_amt: + if current_amount: + frappe.msgprint( + _("System will not check over billing since amount for Item {0} in {1} is zero").format( + item.item_code, ref_dt + ), + title=_("Warning"), + indicator="orange", + ) + continue + + ref_wise_billed_amount.setdefault( + key, + frappe._dict(item_code=item.item_code, billed_amt=0.0, ref_amt=ref_amt, rows=[]), + ) + ref_wise_billed_amount[key]["rows"].append(item.idx) + ref_wise_billed_amount[key]["ref_amt"] = ref_amt + ref_wise_billed_amount[key]["billed_amt"] += current_amount + if key in already_billed: + ref_wise_billed_amount[key]["billed_amt"] += flt(already_billed.pop(key, 0), precision) + + return ref_wise_billed_amount + + +def get_billing_reference_details( + doc, reference_names: list, reference_doctype: str, based_on: str +) -> frappe._dict: + return frappe._dict( + frappe.get_all( + reference_doctype, + filters={"name": ("in", reference_names)}, + fields=["name", based_on], + as_list=1, + ) + ) + + +def get_already_billed_amount(doc, reference_names: list, item_ref_dn: str, based_on: str) -> frappe._dict: + item_doctype = frappe.qb.DocType(doc.items[0].doctype) + based_on_field = frappe.qb.Field(based_on) + join_field = frappe.qb.Field(item_ref_dn) + + return frappe._dict( + ( + frappe.qb.from_(item_doctype) + .select(join_field, Sum(based_on_field)) + .where(join_field.isin(reference_names)) + .where((item_doctype.docstatus == 1) & (item_doctype.parent != doc.name)) + .groupby(join_field) + ).run() + ) + + +def throw_overbill_exception(doc, overbilled_items: list, precision: int) -> None: + message = ( + _("
Cannot overbill for the following Items:
") + + "To allow over-billing, please set allowance in Accounts Settings.
") + frappe.throw(_(message)) diff --git a/erpnext/accounts/services/exchange_gain_loss.py b/erpnext/accounts/services/exchange_gain_loss.py new file mode 100644 index 00000000000..b7ed77bc664 --- /dev/null +++ b/erpnext/accounts/services/exchange_gain_loss.py @@ -0,0 +1,237 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Exchange gain/loss journal helpers.""" + +import frappe +from frappe import _, qb +from frappe.utils import flt, get_link_to_form + +from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_dimensions +from erpnext.accounts.general_ledger import get_round_off_account_and_cost_center +from erpnext.accounts.utils import create_gain_loss_journal, get_currency_precision + + +def make_precision_loss_gl_entry(doc, gl_entries: list) -> None: + round_off_account, round_off_cost_center, _ = get_round_off_account_and_cost_center( + doc.company, "Purchase Invoice", doc.name, doc.use_company_roundoff_cost_center + ) + + precision_loss = doc.get("base_net_total") - flt( + doc.get("net_total") * doc.conversion_rate, doc.precision("net_total") + ) + + credit_or_debit = "credit" if doc.doctype == "Purchase Invoice" else "debit" + against = doc.supplier if doc.doctype == "Purchase Invoice" else doc.customer + + if precision_loss: + gl_entries.append( + doc.get_gl_dict( + { + "account": round_off_account, + "against": against, + credit_or_debit: precision_loss, + "cost_center": round_off_cost_center + if doc.use_company_roundoff_cost_center + else doc.cost_center or round_off_cost_center, + "remarks": _("Net total calculation precision loss"), + } + ) + ) + + +def gain_loss_journal_already_booked( + gain_loss_account: str, + exc_gain_loss: float, + ref2_dt: str, + ref2_dn: str, + ref2_detail_no: str, +) -> bool: + """Check if a gain/loss journal has already been booked for the given parameters.""" + if res := frappe.db.get_all( + "Journal Entry Account", + filters={ + "docstatus": 1, + "account": gain_loss_account, + "reference_type": ref2_dt, + "reference_name": ref2_dn, + "reference_detail_no": ref2_detail_no, + }, + pluck="parent", + ): + res = list({x for x in res}) + if exc_vouchers := frappe.db.get_all( + "Journal Entry", + filters={"name": ["in", res], "voucher_type": "Exchange Gain Or Loss"}, + fields=["voucher_type", "total_debit", "total_credit"], + ): + booked_voucher = exc_vouchers[0] + if ( + booked_voucher.total_debit == exc_gain_loss + and booked_voucher.total_credit == exc_gain_loss + and booked_voucher.voucher_type == "Exchange Gain Or Loss" + ): + return True + return False + + +def make_exchange_gain_loss_journal( + doc, args: dict | None = None, dimensions_dict: dict | None = None +) -> None: + """Make Exchange Gain/Loss journal for Invoices and Payments.""" + # Cancelling existing exchange gain/loss journals is handled during the `on_cancel` event. + # see accounts/utils.py:cancel_exchange_gain_loss_journal() + if doc.docstatus != 1: + return + + if dimensions_dict is None: + dimensions_dict = frappe._dict() + active_dimensions = get_dimensions()[0] + for dim in active_dimensions: + dimensions_dict[dim.fieldname] = doc.get(dim.fieldname) + + if doc.get("doctype") == "Journal Entry": + if args: + precision = get_currency_precision() + for arg in args: + if ( + flt(arg.get("difference_amount", 0), precision) != 0 + or flt(arg.get("exchange_gain_loss", 0), precision) != 0 + ) and arg.get("difference_account"): + party_account = arg.get("account") + gain_loss_account = arg.get("difference_account") + difference_amount = arg.get("difference_amount") or arg.get("exchange_gain_loss") + if difference_amount > 0: + dr_or_cr = "debit" if arg.get("party_type") == "Customer" else "credit" + else: + dr_or_cr = "credit" if arg.get("party_type") == "Customer" else "debit" + + reverse_dr_or_cr = "debit" if dr_or_cr == "credit" else "credit" + + if not gain_loss_journal_already_booked( + gain_loss_account, + difference_amount, + doc.doctype, + doc.name, + arg.get("referenced_row"), + ): + posting_date = arg.get("difference_posting_date") or frappe.db.get_value( + arg.voucher_type, arg.voucher_no, "posting_date" + ) + je = create_gain_loss_journal( + doc.company, + posting_date, + arg.get("party_type"), + arg.get("party"), + party_account, + gain_loss_account, + difference_amount, + dr_or_cr, + reverse_dr_or_cr, + arg.get("against_voucher_type"), + arg.get("against_voucher"), + arg.get("idx"), + doc.doctype, + doc.name, + arg.get("referenced_row"), + arg.get("cost_center"), + dimensions_dict, + arg.get("project"), + ) + frappe.msgprint( + _("Exchange Gain/Loss amount has been booked through {0}").format( + get_link_to_form("Journal Entry", je) + ) + ) + + if doc.get("doctype") == "Payment Entry": + gain_loss_to_book = [x for x in doc.references if x.exchange_gain_loss != 0] + booked = [] + if gain_loss_to_book: + je = qb.DocType("Journal Entry") + jea = qb.DocType("Journal Entry Account") + parents = ( + qb.from_(jea) + .select(jea.parent) + .where( + (jea.reference_type == "Payment Entry") + & (jea.reference_name == doc.name) + & (jea.docstatus == 1) + ) + .run() + ) + + if parents: + booked = ( + qb.from_(je) + .inner_join(jea) + .on(je.name == jea.parent) + .select(jea.reference_type, jea.reference_name, jea.reference_detail_no) + .where( + (je.docstatus == 1) + & (je.name.isin(parents)) + & (je.voucher_type == "Exchange Gain or Loss") + ) + .run() + ) + + for d in gain_loss_to_book: + if d.exchange_gain_loss and ((d.reference_doctype, d.reference_name, str(d.idx)) not in booked): + if doc.book_advance_payments_in_separate_party_account: + party_account = d.account + else: + if doc.payment_type == "Receive": + party_account = doc.paid_from + elif doc.payment_type == "Pay": + party_account = doc.paid_to + + dr_or_cr = "debit" if d.exchange_gain_loss > 0 else "credit" + + if is_payable_account(d.reference_doctype, party_account): + dr_or_cr = "debit" if dr_or_cr == "credit" else "credit" + + reverse_dr_or_cr = "debit" if dr_or_cr == "credit" else "credit" + + gain_loss_account = frappe.get_cached_value( + "Company", doc.company, "exchange_gain_loss_account" + ) + je = create_gain_loss_journal( + doc.company, + args.get("difference_posting_date") if args else doc.posting_date, + doc.party_type, + doc.party, + party_account, + gain_loss_account, + d.exchange_gain_loss, + dr_or_cr, + reverse_dr_or_cr, + d.reference_doctype, + d.reference_name, + d.idx, + doc.doctype, + doc.name, + d.idx, + doc.cost_center, + dimensions_dict, + doc.project, + ) + frappe.msgprint( + _("Exchange Gain/Loss amount has been booked through {0}").format( + get_link_to_form("Journal Entry", je) + ) + ) + + +def is_payable_account(reference_doctype: str, account: str) -> bool: + if reference_doctype == "Purchase Invoice" or ( + reference_doctype == "Journal Entry" + and frappe.get_cached_value("Account", account, "account_type") == "Payable" + ): + return True + return False + + +def set_transaction_currency_and_rate_in_gl_map(doc, gl_entries: list) -> None: + for entry in gl_entries: + entry["transaction_currency"] = doc.currency + entry["transaction_exchange_rate"] = doc.get("conversion_rate") or 1 diff --git a/erpnext/accounts/services/payment_schedule.py b/erpnext/accounts/services/payment_schedule.py new file mode 100644 index 00000000000..bc593a94c7b --- /dev/null +++ b/erpnext/accounts/services/payment_schedule.py @@ -0,0 +1,373 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Payment schedule and payment terms helpers.""" + +import frappe +from frappe import _ +from frappe.utils import DateTimeLikeObject, add_days, add_months, cint, flt, get_last_day, getdate + +from erpnext.accounts.party import get_party_account_currency + + +def set_payment_schedule(doc) -> None: + if (doc.doctype == "Sales Invoice" and doc.is_pos) or doc.get("is_opening") == "Yes": + doc.payment_terms_template = "" + return + + party_account_currency = doc.get("party_account_currency") + if not party_account_currency: + party_type, party = doc.get_party() + if party_type and party: + party_account_currency = get_party_account_currency(party_type, party, doc.company) + + posting_date = doc.get("bill_date") or doc.get("posting_date") or doc.get("transaction_date") + due_date = doc.get("due_date") or posting_date + + base_grand_total = flt(doc.get("base_rounded_total") or doc.base_grand_total) + grand_total = flt(doc.get("rounded_total") or doc.grand_total) + automatically_fetch_payment_terms = 0 + + if doc.doctype in ("Sales Invoice", "Purchase Invoice", "Sales Order"): + po_or_so, doctype, fieldname = get_order_details(doc) + automatically_fetch_payment_terms = cint( + frappe.get_single_value("Accounts Settings", "automatically_fetch_payment_terms") + ) + if doc.doctype != "Sales Order": + base_grand_total = base_grand_total - flt(doc.base_write_off_amount) + grand_total = grand_total - flt(doc.write_off_amount) + + if doc.get("total_advance"): + if party_account_currency == doc.company_currency: + base_grand_total -= doc.get("total_advance") + grand_total = flt(base_grand_total / doc.get("conversion_rate"), doc.precision("grand_total")) + else: + grand_total -= doc.get("total_advance") + base_grand_total = flt( + grand_total * doc.get("conversion_rate"), doc.precision("base_grand_total") + ) + + if not doc.get("payment_schedule"): + if ( + doc.doctype in ["Sales Invoice", "Purchase Invoice", "Sales Order"] + and automatically_fetch_payment_terms + and linked_order_has_payment_terms(doc, po_or_so, fieldname, doctype) + ): + fetch_payment_terms_from_order( + doc, po_or_so, doctype, grand_total, base_grand_total, automatically_fetch_payment_terms + ) + if doc.get("payment_terms_template"): + doc.ignore_default_payment_terms_template = 1 + elif doc.get("payment_terms_template"): + data = get_payment_terms(doc.payment_terms_template, posting_date, grand_total, base_grand_total) + for item in data: + doc.append("payment_schedule", item) + elif doc.doctype not in ["Purchase Receipt"]: + doc.append( + "payment_schedule", + dict( + due_date=due_date, + invoice_portion=100, + payment_amount=grand_total, + base_payment_amount=base_grand_total, + ), + ) + + allocate_payment_based_on_payment_terms = frappe.db.get_value( + "Payment Terms Template", + doc.payment_terms_template, + "allocate_payment_based_on_payment_terms", + ) + + if not ( + automatically_fetch_payment_terms + and allocate_payment_based_on_payment_terms + and linked_order_has_payment_terms(doc, po_or_so, fieldname, doctype) + ): + for d in doc.get("payment_schedule"): + if d.invoice_portion: + d.payment_amount = flt( + grand_total * flt(d.invoice_portion) / 100, d.precision("payment_amount") + ) + d.base_payment_amount = flt( + base_grand_total * flt(d.invoice_portion) / 100, d.precision("base_payment_amount") + ) + d.outstanding = d.payment_amount + d.base_outstanding = d.base_payment_amount + elif not d.invoice_portion: + d.base_payment_amount = flt( + d.payment_amount * doc.get("conversion_rate"), d.precision("base_payment_amount") + ) + d.base_outstanding = d.base_payment_amount + else: + fetch_payment_terms_from_order( + doc, po_or_so, doctype, grand_total, base_grand_total, automatically_fetch_payment_terms + ) + doc.ignore_default_payment_terms_template = 1 + + +def get_order_details(doc) -> tuple: + if not doc.get("items"): + return None, None, None + if doc.doctype == "Sales Invoice": + prev_doc = doc.get("items")[0].get("sales_order") + prev_doctype = "Sales Order" + prev_doctype_name = "sales_order" + elif doc.doctype == "Purchase Invoice": + prev_doc = doc.get("items")[0].get("purchase_order") + prev_doctype = "Purchase Order" + prev_doctype_name = "purchase_order" + else: + prev_doc = doc.get("items")[0].get("prevdoc_docname") + prev_doctype = "Quotation" + prev_doctype_name = "prevdoc_docname" + return prev_doc, prev_doctype, prev_doctype_name + + +def linked_order_has_payment_terms(doc, po_or_so, fieldname, doctype) -> bool: + if po_or_so and all_items_have_same_po_or_so(doc, po_or_so, fieldname): + if linked_order_has_payment_terms_template(po_or_so, doctype): + return True + elif linked_order_has_payment_schedule(po_or_so): + return True + return False + + +def all_items_have_same_po_or_so(doc, po_or_so, fieldname) -> bool: + for item in doc.get("items"): + if item.get(fieldname) != po_or_so: + return False + return True + + +def linked_order_has_payment_terms_template(po_or_so, doctype) -> str | None: + return frappe.get_value(doctype, po_or_so, "payment_terms_template") + + +def linked_order_has_payment_schedule(po_or_so) -> list: + return frappe.get_all("Payment Schedule", filters={"parent": po_or_so}) + + +def fetch_payment_terms_from_order( + doc, po_or_so, po_or_so_doctype, grand_total, base_grand_total, automatically_fetch_payment_terms +) -> None: + """Fetch Payment Terms from Purchase/Sales Order when creating a new invoice.""" + po_or_so = frappe.get_cached_doc(po_or_so_doctype, po_or_so) + + doc.payment_schedule = [] + doc.payment_terms_template = po_or_so.payment_terms_template + posting_date = doc.get("bill_date") or doc.get("posting_date") or doc.get("transaction_date") + + for schedule in po_or_so.payment_schedule: + payment_schedule = { + "payment_term": schedule.payment_term, + "due_date": schedule.due_date, + "invoice_portion": schedule.invoice_portion, + "mode_of_payment": schedule.mode_of_payment, + "description": schedule.description, + "paid_amount": schedule.paid_amount, + } + + 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"), + ) + payment_schedule["base_payment_amount"] = flt( + base_grand_total * flt(payment_schedule["invoice_portion"]) / 100, + schedule.precision("base_payment_amount"), + ) + payment_schedule["outstanding"] = payment_schedule["payment_amount"] + else: + payment_schedule["base_payment_amount"] = flt( + schedule.base_payment_amount * doc.get("conversion_rate"), + schedule.precision("base_payment_amount"), + ) + + if schedule.discount_type == "Percentage": + payment_schedule["discount_type"] = schedule.discount_type + payment_schedule["discount"] = schedule.discount + + if not schedule.invoice_portion: + payment_schedule["payment_amount"] = schedule.payment_amount + + doc.append("payment_schedule", payment_schedule) + + +def set_due_date(doc) -> None: + due_dates = [d.due_date for d in doc.get("payment_schedule") if d.due_date] + if due_dates: + doc.due_date = max(due_dates) + + +def validate_payment_schedule_dates(doc) -> None: + dates = [] + li = [] + + if doc.doctype == "Sales Invoice" and doc.is_pos: + return + + for d in doc.get("payment_schedule"): + d.validate_from_to_dates("discount_date", "due_date") + if doc.doctype in ["Sales Order", "Quotation"] and getdate(d.due_date) < getdate( + doc.transaction_date + ): + frappe.throw( + _("Row {0}: Due Date in the Payment Terms table cannot be before Posting Date").format(d.idx) + ) + elif d.due_date in dates: + li.append(_("{0} in row {1}").format(d.due_date, d.idx)) + dates.append(d.due_date) + + if li: + frappe.throw( + _("Rows with duplicate due dates in other rows were found: {0}").format("Cannot overbill for the following Items:
") - + "To allow over-billing, please set allowance in Accounts Settings.
") - - frappe.throw(_(message)) + throw_overbill_exception(self, overbilled_items, precision) def get_company_default(self, fieldname, ignore_validation=False): from erpnext.accounts.utils import get_company_default @@ -2235,285 +1904,64 @@ class AccountsController(TransactionBase): for item in duplicate_list: self.remove(item) - def set_payment_schedule(self): - if (self.doctype == "Sales Invoice" and self.is_pos) or self.get("is_opening") == "Yes": - self.payment_terms_template = "" - return + def set_payment_schedule(self) -> None: + from erpnext.accounts.services.payment_schedule import set_payment_schedule - party_account_currency = self.get("party_account_currency") - if not party_account_currency: - party_type, party = self.get_party() + set_payment_schedule(self) - if party_type and party: - party_account_currency = get_party_account_currency(party_type, party, self.company) + def get_order_details(self) -> tuple: + from erpnext.accounts.services.payment_schedule import get_order_details - posting_date = self.get("bill_date") or self.get("posting_date") or self.get("transaction_date") - date = self.get("due_date") - due_date = date or posting_date + return get_order_details(self) - base_grand_total = flt(self.get("base_rounded_total") or self.base_grand_total) - grand_total = flt(self.get("rounded_total") or self.grand_total) - automatically_fetch_payment_terms = 0 + def linked_order_has_payment_terms(self, po_or_so, fieldname, doctype) -> bool: + from erpnext.accounts.services.payment_schedule import linked_order_has_payment_terms - if self.doctype in ("Sales Invoice", "Purchase Invoice", "Sales Order"): - po_or_so, doctype, fieldname = self.get_order_details() - automatically_fetch_payment_terms = cint( - frappe.get_single_value("Accounts Settings", "automatically_fetch_payment_terms") - ) - if self.doctype != "Sales Order": - base_grand_total = base_grand_total - flt(self.base_write_off_amount) - grand_total = grand_total - flt(self.write_off_amount) + return linked_order_has_payment_terms(self, po_or_so, fieldname, doctype) - if self.get("total_advance"): - if party_account_currency == self.company_currency: - base_grand_total -= self.get("total_advance") - grand_total = flt( - base_grand_total / self.get("conversion_rate"), self.precision("grand_total") - ) - else: - grand_total -= self.get("total_advance") - base_grand_total = flt( - grand_total * self.get("conversion_rate"), self.precision("base_grand_total") - ) + def all_items_have_same_po_or_so(self, po_or_so, fieldname) -> bool: + from erpnext.accounts.services.payment_schedule import all_items_have_same_po_or_so - if not self.get("payment_schedule"): - if ( - self.doctype in ["Sales Invoice", "Purchase Invoice", "Sales Order"] - and automatically_fetch_payment_terms - and self.linked_order_has_payment_terms(po_or_so, fieldname, doctype) - ): - self.fetch_payment_terms_from_order( - po_or_so, doctype, grand_total, base_grand_total, automatically_fetch_payment_terms - ) - if self.get("payment_terms_template"): - self.ignore_default_payment_terms_template = 1 - elif self.get("payment_terms_template"): - data = get_payment_terms( - self.payment_terms_template, posting_date, grand_total, base_grand_total - ) - for item in data: - self.append("payment_schedule", item) - elif self.doctype not in ["Purchase Receipt"]: - data = dict( - due_date=due_date, - invoice_portion=100, - payment_amount=grand_total, - base_payment_amount=base_grand_total, - ) - self.append("payment_schedule", data) + return all_items_have_same_po_or_so(self, po_or_so, fieldname) - allocate_payment_based_on_payment_terms = frappe.db.get_value( - "Payment Terms Template", self.payment_terms_template, "allocate_payment_based_on_payment_terms" - ) + def linked_order_has_payment_terms_template(self, po_or_so, doctype) -> str | None: + from erpnext.accounts.services.payment_schedule import linked_order_has_payment_terms_template - if not ( - automatically_fetch_payment_terms - and allocate_payment_based_on_payment_terms - and self.linked_order_has_payment_terms(po_or_so, fieldname, doctype) - ): - for d in self.get("payment_schedule"): - if d.invoice_portion: - d.payment_amount = flt( - grand_total * flt(d.invoice_portion) / 100, d.precision("payment_amount") - ) - d.base_payment_amount = flt( - base_grand_total * flt(d.invoice_portion) / 100, d.precision("base_payment_amount") - ) - d.outstanding = d.payment_amount - d.base_outstanding = d.base_payment_amount - elif not d.invoice_portion: - d.base_payment_amount = flt( - d.payment_amount * self.get("conversion_rate"), d.precision("base_payment_amount") - ) - d.base_outstanding = d.base_payment_amount - else: - self.fetch_payment_terms_from_order( - po_or_so, doctype, grand_total, base_grand_total, automatically_fetch_payment_terms - ) - self.ignore_default_payment_terms_template = 1 + return linked_order_has_payment_terms_template(po_or_so, doctype) - def get_order_details(self): - if not self.get("items"): - return None, None, None - if self.doctype == "Sales Invoice": - prev_doc = self.get("items")[0].get("sales_order") - prev_doctype = "Sales Order" - prev_doctype_name = "sales_order" - elif self.doctype == "Purchase Invoice": - prev_doc = self.get("items")[0].get("purchase_order") - prev_doctype = "Purchase Order" - prev_doctype_name = "purchase_order" - else: - prev_doc = self.get("items")[0].get("prevdoc_docname") - prev_doctype = "Quotation" - prev_doctype_name = "prevdoc_docname" - return prev_doc, prev_doctype, prev_doctype_name + def linked_order_has_payment_schedule(self, po_or_so) -> list: + from erpnext.accounts.services.payment_schedule import linked_order_has_payment_schedule - def linked_order_has_payment_terms(self, po_or_so, fieldname, doctype): - if po_or_so and self.all_items_have_same_po_or_so(po_or_so, fieldname): - if self.linked_order_has_payment_terms_template(po_or_so, doctype): - return True - elif self.linked_order_has_payment_schedule(po_or_so): - return True - - return False - - def all_items_have_same_po_or_so(self, po_or_so, fieldname): - for item in self.get("items"): - if item.get(fieldname) != po_or_so: - return False - - return True - - def linked_order_has_payment_terms_template(self, po_or_so, doctype): - return frappe.get_value(doctype, po_or_so, "payment_terms_template") - - def linked_order_has_payment_schedule(self, po_or_so): - return frappe.get_all("Payment Schedule", filters={"parent": po_or_so}) + return linked_order_has_payment_schedule(po_or_so) def fetch_payment_terms_from_order( - self, po_or_so, po_or_so_doctype, grand_total, base_grand_total, automatically_fetch_payment_terms - ): - """ - Fetch Payment Terms from Purchase/Sales Order on creating a new Purchase/Sales Invoice. - """ - po_or_so = frappe.get_cached_doc(po_or_so_doctype, po_or_so) + self, + po_or_so, + po_or_so_doctype, + grand_total, + base_grand_total, + automatically_fetch_payment_terms, + ) -> None: + from erpnext.accounts.services.payment_schedule import fetch_payment_terms_from_order - 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") + fetch_payment_terms_from_order( + self, po_or_so, po_or_so_doctype, grand_total, base_grand_total, automatically_fetch_payment_terms + ) - for schedule in po_or_so.payment_schedule: - payment_schedule = { - "payment_term": schedule.payment_term, - "due_date": schedule.due_date, - "invoice_portion": schedule.invoice_portion, - "mode_of_payment": schedule.mode_of_payment, - "description": schedule.description, - "paid_amount": schedule.paid_amount, - } + def set_due_date(self) -> None: + from erpnext.accounts.services.payment_schedule import set_due_date - 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) + set_due_date(self) - 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) + def validate_payment_schedule_dates(self) -> None: + from erpnext.accounts.services.payment_schedule import validate_payment_schedule_dates - payment_schedule["payment_amount"] = flt( - grand_total * flt(payment_schedule["invoice_portion"]) / 100, - schedule.precision("payment_amount"), - ) - payment_schedule["base_payment_amount"] = flt( - base_grand_total * flt(payment_schedule["invoice_portion"]) / 100, - schedule.precision("base_payment_amount"), - ) - payment_schedule["outstanding"] = payment_schedule["payment_amount"] - else: - payment_schedule["base_payment_amount"] = flt( - schedule.base_payment_amount * self.get("conversion_rate"), - schedule.precision("base_payment_amount"), - ) + validate_payment_schedule_dates(self) - if schedule.discount_type == "Percentage": - payment_schedule["discount_type"] = schedule.discount_type - payment_schedule["discount"] = schedule.discount + def validate_payment_schedule_amount(self) -> None: + from erpnext.accounts.services.payment_schedule import validate_payment_schedule_amount - if not schedule.invoice_portion: - payment_schedule["payment_amount"] = schedule.payment_amount - - self.append("payment_schedule", payment_schedule) - - def set_due_date(self): - due_dates = [d.due_date for d in self.get("payment_schedule") if d.due_date] - if due_dates: - self.due_date = max(due_dates) - - def validate_payment_schedule_dates(self): - dates = [] - li = [] - - if self.doctype == "Sales Invoice" and self.is_pos: - return - - for d in self.get("payment_schedule"): - d.validate_from_to_dates("discount_date", "due_date") - if self.doctype in ["Sales Order", "Quotation"] and getdate(d.due_date) < getdate( - self.transaction_date - ): - frappe.throw( - _("Row {0}: Due Date in the Payment Terms table cannot be before Posting Date").format( - d.idx - ) - ) - elif d.due_date in dates: - li.append(_("{0} in row {1}").format(d.due_date, d.idx)) - dates.append(d.due_date) - - if li: - duplicates = "