diff --git a/eu_einvoice/european_e_invoice/custom/sales_invoice.py b/eu_einvoice/european_e_invoice/custom/sales_invoice.py index 32a3edbf..515aa63a 100644 --- a/eu_einvoice/european_e_invoice/custom/sales_invoice.py +++ b/eu_einvoice/european_e_invoice/custom/sales_invoice.py @@ -19,7 +19,7 @@ from frappe.core.utils import html2text from frappe.model.naming import parse_naming_series from frappe.utils import cstr -from frappe.utils.data import date_diff, flt, getdate, to_markdown +from frappe.utils.data import cint, date_diff, flt, getdate, to_markdown from eu_einvoice.common_codes import CommonCodeRetriever from eu_einvoice.schematron import get_validation_errors @@ -502,6 +502,23 @@ def _add_line_item(self, item: SalesInvoiceItem): li.settlement.monetary_summation.total_amount = flt(item.net_amount, item.precision("net_amount")) self.doc.trade.items.add(li) + def _sum_item_net_matching_on_net_total_rate(self, tax) -> float | None: + """Sum ``net_amount`` for items whose resolved VAT % matches this tax row (multi-rate invoices).""" + tax_row_vat_percent = flt( + tax.rate or frappe.db.get_value("Account", tax.account_head, "tax_rate") or 0.0 + ) + if not tax_row_vat_percent: + return 0.0 + sum_net_amount = 0 + for line_item in self.invoice.items: + item_rate = get_item_rate(line_item.item_tax_template, self.invoice.taxes) + if item_rate is None: + return None + if flt(item_rate) == tax_row_vat_percent: + sum_net_amount += line_item.net_amount + + return flt(sum_net_amount, self.invoice.precision("net_total")) + def _add_taxes_and_charges(self): tax_added = False for i, tax in enumerate(self.invoice.taxes): @@ -528,6 +545,14 @@ def _add_taxes_and_charges(self): self.doc.trade.settlement.service_charge.add(service_charge) elif tax.charge_type == "On Net Total": + tax_rate = tax.rate or frappe.db.get_value("Account", tax.account_head, "tax_rate") or 0 + # Skip rows with 0% tax rate and non-zero tax amount: Assumption here is that if a tax_rate + # is not 0% and the tax amount is 0, then the tax is not applicable, as no item with + # this tax rate exists and hence it would throw validation errors. + # Possible 0% tax rates and their codes can be applied as 0% VAT, does not + # create a tax_amount. + if tax.tax_amount == 0 and tax_rate != 0: + continue trade_tax = ApplicableTradeTax() trade_tax.calculated_amount = tax.tax_amount trade_tax.type_code = "VAT" @@ -538,7 +563,7 @@ def _add_taxes_and_charges(self): ("Sales Taxes and Charges Template", self.invoice.taxes_and_charges), ] ) - tax_rate = tax.rate or frappe.db.get_value("Account", tax.account_head, "tax_rate") or 0 + trade_tax.rate_applicable_percent = tax_rate if len(self.invoice.taxes) == 1: @@ -548,15 +573,15 @@ def _add_taxes_and_charges(self): # We only have one tax rate on the line items, but it was not specified on the tax row # so we use the tax rate from the line items. trade_tax.rate_applicable_percent = self.item_tax_rates.pop() - elif hasattr(tax, "net_amount"): - trade_tax.basis_amount = tax.net_amount - elif hasattr(tax, "custom_net_amount"): - trade_tax.basis_amount = tax.custom_net_amount - elif tax.tax_amount and tax_rate: - # We don't know the basis amount for this tax, so we try to calculate it - trade_tax.basis_amount = round(tax.tax_amount / tax_rate * 100, 2) else: - trade_tax.basis_amount = 0 + basis = ( + self._sum_item_net_matching_on_net_total_rate(tax) + or (hasattr(tax, "net_amount") and flt(tax.net_amount)) + or (hasattr(tax, "custom_net_amount") and flt(tax.custom_net_amount)) + or (tax.tax_amount and tax_rate and round(tax.tax_amount / tax_rate * 100, 2)) + or 0 + ) + trade_tax.basis_amount = basis self.doc.trade.settlement.trade_tax.add(trade_tax) tax_added = True @@ -947,19 +972,36 @@ def _attach_xml_file(doc: SalesInvoice, xml_content: bytes, field_name: str | No doc.db_set(field_name, file_doc.file_url) -def get_item_rate(item_tax_template: str | None, taxes: list[dict]) -> float | None: - """Get the tax rate for an item from the item tax template and the taxes table.""" +def get_item_rate(item_tax_template: str | None, taxes: list) -> float | None: + """Resolve the VAT % for this line from the Item Tax Template and the invoice tax rows. + + 1) Return the ``tax_rate`` of the first template row (in template order) whose ``tax_type`` + matches one of the invoice's ``account_head`` values. On ERPNext v15 and earlier, only + non-zero rates are considered. From v16 (``not_applicable`` on **Item Tax Template Detail**), + zero rates are included and rows with ``not_applicable`` set are excluded. + 2) If the template did not match: if there is exactly one *On Net Total* row, use its ``rate``. + """ if item_tax_template: - # match the accounts from the taxes table with the rate from the item tax template - tax_template = frappe.get_doc("Item Tax Template", item_tax_template) + tax_template = frappe.get_cached_doc("Item Tax Template", item_tax_template) applicable_accounts = [tax.account_head for tax in taxes if tax.account_head] + has_not_applicable = bool(frappe.get_meta("Item Tax Template Detail").get_field("not_applicable")) + + def _template_row_matches(item_tax) -> bool: + if item_tax.tax_type not in applicable_accounts: + return False + if has_not_applicable: + return not cint(getattr(item_tax, "not_applicable", 0)) + return bool(item_tax.tax_rate) for item_tax in tax_template.taxes: - if item_tax.tax_type in applicable_accounts: + if _template_row_matches(item_tax): return item_tax.tax_rate - # if only one tax is on net total, return its rate - tax_rates = [invoice_tax.rate for invoice_tax in taxes if invoice_tax.charge_type == "On Net Total"] + tax_rates = [ + invoice_tax.rate + for invoice_tax in taxes + if invoice_tax.charge_type == "On Net Total" and invoice_tax.rate is not None + ] return tax_rates[0] if len(tax_rates) == 1 else None