From bb100db815531307e602b18ba259e380c69be894 Mon Sep 17 00:00:00 2001 From: Tyler Matteson Date: Thu, 21 Mar 2024 20:11:15 -0400 Subject: [PATCH 01/16] wip: payment entry saves outstanding amount is not updated some other warnings need to be defeated --- .pre-commit-config.yaml | 2 +- .../custom/sales_taxes_and_charges.json | 236 ++++++++++++++++++ .../check_run/doctype/check_run/check_run.py | 45 +++- check_run/hooks.py | 4 +- check_run/overrides/payment_entry.py | 139 +++++++++-- check_run/overrides/sales_invoice.py | 54 ++++ .../overrides/sales_taxes_and_charges.py | 13 + check_run/public/js/check_run/CheckRun.vue | 2 +- check_run/tests/fixtures.py | 110 ++++++++ check_run/tests/setup.py | 97 ++++++- 10 files changed, 678 insertions(+), 24 deletions(-) create mode 100644 check_run/check_run/custom/sales_taxes_and_charges.json create mode 100644 check_run/overrides/sales_invoice.py create mode 100644 check_run/overrides/sales_taxes_and_charges.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bbdc3b2f..237555f8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -45,7 +45,7 @@ repos: additional_dependencies: ['flake8-bugbear'] - repo: https://github.com/agritheory/test_utils - rev: v1.20.0 + rev: v1.20.4 hooks: - id: update_pre_commit_config - id: validate_frappe_project diff --git a/check_run/check_run/custom/sales_taxes_and_charges.json b/check_run/check_run/custom/sales_taxes_and_charges.json new file mode 100644 index 00000000..aa1ccf75 --- /dev/null +++ b/check_run/check_run/custom/sales_taxes_and_charges.json @@ -0,0 +1,236 @@ +{ + "custom_fields": [ + { + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "default": null, + "description": "Computed from Party's Default Payment Terms Template", + "dt": "Sales Taxes and Charges", + "fetch_if_empty": 0, + "fieldname": "due_date", + "fieldtype": "Date", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 12, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "rate", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Due Date", + "length": 0, + "module": "Check Run", + "name": "Sales Taxes and Charges-due_date", + "no_copy": 0, + "non_negative": 0, + "permlevel": 0, + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 1, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "default": null, + "dt": "Sales Taxes and Charges", + "fetch_if_empty": 0, + "fieldname": "party", + "fieldtype": "Dynamic Link", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 16, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "party_type", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Party", + "length": 0, + "module": "Check Run", + "name": "Sales Taxes and Charges-party", + "no_copy": 0, + "non_negative": 0, + "options": "party_type", + "permlevel": 0, + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "default": null, + "dt": "Sales Taxes and Charges", + "fetch_if_empty": 0, + "fieldname": "column_break_w4usy", + "fieldtype": "Column Break", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 14, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "due_date", + "is_system_generated": 0, + "is_virtual": 0, + "length": 0, + "module": "Check Run", + "name": "Sales Taxes and Charges-column_break_w4usy", + "no_copy": 0, + "non_negative": 0, + "permlevel": 0, + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "default": null, + "dt": "Sales Taxes and Charges", + "fetch_if_empty": 0, + "fieldname": "party_type", + "fieldtype": "Link", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 15, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "column_break_w4usy", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Party Type", + "length": 0, + "module": "Check Run", + "name": "Sales Taxes and Charges-party_type", + "no_copy": 0, + "non_negative": 0, + "options": "DocType", + "permlevel": 0, + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "default": null, + "dt": "Sales Taxes and Charges", + "fetch_if_empty": 0, + "fieldname": "outstanding_amount", + "fieldtype": "Currency", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 25, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "base_tax_amount_after_discount_amount", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Outstanding Amount", + "length": 0, + "module": "Check Run", + "name": "Sales Taxes and Charges-outstanding_amount", + "no_copy": 0, + "non_negative": 0, + "permlevel": 0, + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 1, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0 + } + ], + "doctype": "Sales Taxes and Charges", + "property_setters": [ + { + "doc_type": "Sales Taxes and Charges", + "doctype_or_field": "DocType", + "idx": 0, + "is_system_generated": 0, + "module": "Check Run", + "name": "Sales Taxes and Charges-main-field_order", + "property": "field_order", + "property_type": "Data", + "value": "[\"charge_type\", \"row_id\", \"account_head\", \"col_break_1\", \"description\", \"included_in_print_rate\", \"included_in_paid_amount\", \"accounting_dimensions_section\", \"cost_center\", \"dimension_col_break\", \"section_break_8\", \"rate\", \"due_date\", \"column_break_w4usy\", \"party_type\", \"party\", \"section_break_9\", \"account_currency\", \"tax_amount\", \"total\", \"tax_amount_after_discount_amount\", \"column_break_13\", \"base_tax_amount\", \"base_total\", \"base_tax_amount_after_discount_amount\", \"outstanding_amount\", \"item_wise_tax_detail\", \"dont_recompute_tax\"]" + } + ], + "sync_on_migrate": 1 +} \ No newline at end of file diff --git a/check_run/check_run/doctype/check_run/check_run.py b/check_run/check_run/doctype/check_run/check_run.py index 39440b95..811b8373 100644 --- a/check_run/check_run/doctype/check_run/check_run.py +++ b/check_run/check_run/doctype/check_run/check_run.py @@ -323,6 +323,8 @@ def create_payment_entries(self, transactions: list[frappe._dict]) -> list[frapp party = frappe.db.get_value("Expense Claim", group[0].name, "employee") elif group[0].doctype == "Journal Entry": party = frappe.db.get_value("Journal Entry Account", group[0].name, "party") + elif group[0].doctype == "Sales Invoice": + party = group[0].party pe = frappe.new_doc("Payment Entry") pe.payment_type = "Pay" pe.posting_date = ( @@ -368,14 +370,17 @@ def create_payment_entries(self, transactions: list[frappe._dict]) -> list[frapp discount_amount, has_discount = calculate_payment_term_discount(reference, self.posting_date) total_discount_amount += discount_amount - if reference.doctype == "Journal Entry": + if reference.doctype in ("Journal Entry", "Sales Invoice"): reference_name = reference.ref_number else: reference_name = reference.name or reference.ref_number + pe.append( "references", { - "reference_doctype": reference.doctype, + "reference_doctype": reference.doctype + if reference.doctype != "Sales Invoice" + else "Sales Taxes and Charges", "reference_name": reference_name, "due_date": reference.get("due_date"), "outstanding_amount": flt(reference.amount), @@ -797,6 +802,39 @@ def get_entries(doc: CheckRun | str) -> dict: .where((journal_entries.name).notin(sub_q)) # codespell:ignore ) + # build Sales Taxes and Charges query + sales_taxes = frappe.qb.DocType("Sales Taxes and Charges") + sales_invoice = frappe.qb.DocType("Sales Invoice") + st_qb = ( + frappe.qb.from_(sales_taxes) + .inner_join(sales_invoice) + .on(sales_taxes.parent == sales_invoice.name) + .inner_join(suppliers) + .on(suppliers.name == sales_taxes.party) + .select( + ConstantColumn("Sales Invoice").as_("doctype"), + sales_taxes.party_type, + sales_taxes.parent.as_("ref_number"), + sales_taxes.name, + sales_taxes.party, + (sales_taxes.party).as_("party_name"), + (sales_taxes.tax_amount_after_discount_amount).as_("amount"), + Coalesce(sales_taxes.due_date, sales_invoice.posting_date).as_("due_date"), + sales_invoice.posting_date, + Coalesce( + NullIf(suppliers.supplier_default_mode_of_payment, ""), + f"{settings.tax_payable}" or "\n", + ).as_("mode_of_payment"), + ConstantColumn("").as_("payment_term"), + ) + .where(sales_invoice.company == company) + .where(sales_invoice.docstatus == 1) + .where(sales_taxes.account_head == pay_to_account) + .where(sales_invoice.posting_date <= end_date) + .where(sales_invoice.outstanding_amount > 0.0) + # .where((sales_taxes.name).notin(sub_q)) # TODO: reference not in payment entry references + ) + if not settings: query = pi_qb.union(ec_qb).union(je_qb) else: @@ -805,8 +843,9 @@ def get_entries(doc: CheckRun | str) -> dict: settings.include_purchase_invoices, settings.include_expense_claims, settings.include_journal_entries, + settings.include_tax_payable, ) - for flag, qb in zip(flags, (pi_qb, ec_qb, je_qb)): + for flag, qb in zip(flags, (pi_qb, ec_qb, je_qb, st_qb)): if not flag: continue if not query: diff --git a/check_run/hooks.py b/check_run/hooks.py index fca4d6a5..aa8667e5 100644 --- a/check_run/hooks.py +++ b/check_run/hooks.py @@ -100,9 +100,11 @@ # DocType Class # --------------- # Override standard doctype classes + override_doctype_class = { - # "Bank": "check_run.overrides.bank.CustomBank", "Payment Entry": "check_run.overrides.payment_entry.CheckRunPaymentEntry", + "Sales Invoice": "check_run.overrides.sales_invoice.CheckRunSalesInvoice", + "Sales Taxes and Charges": "check_run.overrides.sales_taxes_and_charges.CheckRunSalesTaxesandCharges", } # Document Events diff --git a/check_run/overrides/payment_entry.py b/check_run/overrides/payment_entry.py index 6075bc47..6c1e1fc8 100644 --- a/check_run/overrides/payment_entry.py +++ b/check_run/overrides/payment_entry.py @@ -3,8 +3,12 @@ import base64 import copy +import json import frappe +from erpnext.accounts.doctype.invoice_discounting.invoice_discounting import ( + get_party_account_based_on_invoice_discounting, +) from erpnext.accounts.doctype.payment_entry.payment_entry import ( PaymentEntry, add_regional_gl_entries, @@ -22,9 +26,9 @@ validate_disabled_accounts, ) from erpnext.accounts.utils import get_payment_ledger_entries, is_immutable_ledger_enabled -from frappe import _, safe_decode +from frappe import _, safe_decode, scrub from frappe.core.doctype.file.utils import get_local_image -from frappe.utils import flt, get_link_to_form, now +from frappe.utils import comma_and, comma_or, flt, get_link_to_form, now from frappe.utils.data import getdate @@ -91,12 +95,84 @@ def get_valid_reference_doctypes(self): if self.party_type == "Customer": return ("Sales Order", "Sales Invoice", "Journal Entry", "Dunning") elif self.party_type == "Supplier": - return ("Purchase Order", "Purchase Invoice", "Journal Entry") + return ( + "Purchase Order", + "Purchase Invoice", + "Journal Entry", + "Sales Taxes and Charges", + ) # Tax Payable elif self.party_type == "Shareholder": return ("Journal Entry",) elif self.party_type == "Employee": return ("Journal Entry", "Expense Claim") # Expense Claim + def validate_reference_documents(self): + valid_reference_doctypes = self.get_valid_reference_doctypes() + + if not valid_reference_doctypes: + return + + for d in self.get("references"): + if not d.allocated_amount: + continue + if d.reference_doctype not in valid_reference_doctypes: + frappe.throw( + _("Reference Doctype must be one of {0}").format( + comma_or(_(d) for d in valid_reference_doctypes) + ) + ) + + elif d.reference_name: + if not frappe.db.exists(d.reference_doctype, d.reference_name): + frappe.throw(_("{0} {1} does not exist").format(d.reference_doctype, d.reference_name)) + else: + ref_doc = frappe.get_doc(d.reference_doctype, d.reference_name) + if d.reference_doctype == "Sales Taxes and Charges": + if self.party != ref_doc.party: + frappe.throw( + _("{0} {1} is not associated with {2} {3}").format( + _(d.reference_doctype), d.reference_name, _(self.party_type), self.party + ) + ) + elif d.reference_doctype != "Journal Entry": + if self.party != ref_doc.get(scrub(self.party_type)): + frappe.throw( + _("{0} {1} is not associated with {2} {3}").format( + _(d.reference_doctype), d.reference_name, _(self.party_type), self.party + ) + ) + else: + self.validate_journal_entry() + + if d.reference_doctype in frappe.get_hooks("invoice_doctypes"): + if self.party_type == "Customer": + ref_party_account = ( + get_party_account_based_on_invoice_discounting(d.reference_name) or ref_doc.debit_to + ) + elif self.party_type == "Supplier": + ref_party_account = ref_doc.credit_to + elif self.party_type == "Employee": + ref_party_account = ref_doc.payable_account + + if ( + ref_party_account != self.party_account + and not self.book_advance_payments_in_separate_party_account + ): + frappe.throw( + _("{0} {1} is associated with {2}, but Party Account is {3}").format( + _(d.reference_doctype), d.reference_name, ref_party_account, self.party_account + ) + ) + + if ref_doc.doctype == "Purchase Invoice" and ref_doc.get("on_hold"): + frappe.throw( + _("{0} {1} is on hold").format(_(d.reference_doctype), d.reference_name), + title=_("Invalid Purchase Invoice"), + ) + + if ref_doc.docstatus != 1: + frappe.throw(_("{0} {1} must be submitted").format(_(d.reference_doctype), d.reference_name)) + """ Because Check Run processes multiple payment entries in a background queue, errors generally do not include enough data to identify the problem since there were written and remain appropriate for the context of an individual @@ -177,20 +253,22 @@ def validate_allocated_amount_with_latest_data(self): d = frappe._dict(d) latest_lookup.setdefault((d.voucher_type, d.voucher_no), frappe._dict())[d.payment_term] = d - 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)) - ) + for idx, d in enumerate(self.get("references"), start=1): + if d.reference_doctype == "Sales Taxes and Charges": + continue + 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) @@ -623,3 +701,30 @@ def set_voided_date(doctype, docname, voided_date): title=_("Invalid Void As Of Date"), ) frappe.db.set_value(doctype, docname, "voided_date", voided_date, update_modified=False) + + +def validate_add_payment_term(doc: PaymentEntry, method: str | None = None): + doc = frappe._dict(json.loads(doc)) if isinstance(doc, str) else doc + if doc.check_run: + return + adjusted_refs = [] + for r in doc.get("references"): + if r.reference_doctype == "Purchase Invoice" and not r.payment_term: + pmt_term = frappe.get_all( + "Payment Schedule", + {"parent": r.reference_name, "outstanding": [">", 0.0]}, + ["payment_term"], + order_by="due_date ASC", + limit=1, + ) + if pmt_term: + r.payment_term = pmt_term[0].get("payment_term") + adjusted_refs.append(r.reference_name) + if adjusted_refs: + frappe.msgprint( + msg=frappe._( + f"An outstanding Payment Schedule term was detected and added for {comma_and(adjusted_refs)} in the references table.
Please review - " + "this field must be filled in for the Payment Schedule to synchronize and to prevent a paid invoice portion from showing up in a Check Run." + ), + title=frappe._("Payment Schedule Term Added"), + ) diff --git a/check_run/overrides/sales_invoice.py b/check_run/overrides/sales_invoice.py new file mode 100644 index 00000000..4d1db8cd --- /dev/null +++ b/check_run/overrides/sales_invoice.py @@ -0,0 +1,54 @@ +# Copyright (c) 2026, AgriTheory and contributors +# For license information, please see license.txt + +import frappe +from erpnext.accounts.doctype.sales_invoice.sales_invoice import SalesInvoice +from erpnext.accounts.utils import get_account_currency +from erpnext.accounts.party import get_due_date +from frappe.utils.data import cint, flt + + +class CheckRunSalesInvoice(SalesInvoice): + # TODO: update due date in taxes with validate hook + def validate(self): + for row in self.taxes: + if not row.party: + continue + due_date = get_due_date(self.posting_date, row.party_type, row.party, self.company) + row.due_date = due_date or self.posting_date + row.outstanding_amount = row.tax_amount + super().validate() + + def on_submit(self): + for row in self.taxes: + row.outstanding_amount = row.tax_amount + super().validate() + + def make_tax_gl_entries(self, gl_entries): + enable_discount_accounting = cint( + frappe.db.get_single_value("Selling Settings", "enable_discount_accounting") + ) + + for tax in self.get("taxes"): + amount, base_amount = self.get_tax_amounts(tax, enable_discount_accounting) + if flt(tax.base_tax_amount_after_discount_amount): + account_currency = get_account_currency(tax.account_head) + gl_entries.append( + self.get_gl_dict( + { + "account": tax.account_head, + "against": self.customer, + "credit": flt(base_amount, tax.precision("tax_amount_after_discount_amount")), + "credit_in_account_currency": ( + flt(base_amount, tax.precision("base_tax_amount_after_discount_amount")) + if account_currency == self.company_currency + else flt(amount, tax.precision("tax_amount_after_discount_amount")) + ), + "cost_center": tax.cost_center, + "party_type": tax.party_type, + "party": tax.party, + }, + account_currency, + item=tax, + ) + ) diff --git a/check_run/overrides/sales_taxes_and_charges.py b/check_run/overrides/sales_taxes_and_charges.py new file mode 100644 index 00000000..3e307eb8 --- /dev/null +++ b/check_run/overrides/sales_taxes_and_charges.py @@ -0,0 +1,13 @@ +# Copyright (c) 2026, AgriTheory and contributors +# For license information, please see license.txt + +import frappe +from erpnext.accounts.doctype.sales_taxes_and_charges.sales_taxes_and_charges import ( + SalesTaxesandCharges, +) + + +class CheckRunSalesTaxesandCharges(SalesTaxesandCharges): + @property + def company(self): + return frappe.get_value("Sales Invoice", self.parent, "company") diff --git a/check_run/public/js/check_run/CheckRun.vue b/check_run/public/js/check_run/CheckRun.vue index 8d0e0f3f..9c5793ff 100644 --- a/check_run/public/js/check_run/CheckRun.vue +++ b/check_run/public/js/check_run/CheckRun.vue @@ -106,7 +106,7 @@ {{ item.party_name || item.party }} - {{ item.ref_number || item.name }} + {{ item.doctype != 'Sales Invoice' ? item.ref_number : item.name }}