From aad504e95fe054bd6239ab1c594ab3cc4309e1b5 Mon Sep 17 00:00:00 2001 From: Preetam Biswal Date: Mon, 2 Feb 2026 16:12:50 +0100 Subject: [PATCH 01/14] fix: make purchase order non-mandatory in matching dialog --- edocument/edocument/profiles/peppol/matcher.py | 1 - 1 file changed, 1 deletion(-) diff --git a/edocument/edocument/profiles/peppol/matcher.py b/edocument/edocument/profiles/peppol/matcher.py index 14ee282..eb90d08 100644 --- a/edocument/edocument/profiles/peppol/matcher.py +++ b/edocument/edocument/profiles/peppol/matcher.py @@ -530,7 +530,6 @@ def _get_dialog_config(matching_data: dict) -> dict: "fieldname": "purchase_order", "label": _("Match to Purchase Order"), "options": "Purchase Order", - "reqd": 1, "default": po_data.get("matched"), } ) From 6edee8d9133eb1ad30987967c23bd74163286a56 Mon Sep 17 00:00:00 2001 From: Preetam Biswal Date: Wed, 4 Feb 2026 22:28:32 +0100 Subject: [PATCH 02/14] fix: add apt_packages.txt for lxml build dependencies --- apt_packages.txt | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 apt_packages.txt diff --git a/apt_packages.txt b/apt_packages.txt new file mode 100644 index 0000000..3e83da4 --- /dev/null +++ b/apt_packages.txt @@ -0,0 +1,2 @@ +libxml2-dev +libxslt-dev From d64eb5b3259e01072eee60a28db97d01d16fb49f Mon Sep 17 00:00:00 2001 From: Preetam Biswal Date: Wed, 4 Feb 2026 22:39:12 +0100 Subject: [PATCH 03/14] fix: enforce required field validation in matching dialog --- edocument/edocument/doctype/edocument/edocument.js | 1 + 1 file changed, 1 insertion(+) diff --git a/edocument/edocument/doctype/edocument/edocument.js b/edocument/edocument/doctype/edocument/edocument.js index 965509f..331f112 100644 --- a/edocument/edocument/doctype/edocument/edocument.js +++ b/edocument/edocument/doctype/edocument/edocument.js @@ -135,6 +135,7 @@ function show_matching_dialog(frm, matching_data, config) { function save_matching(frm, dialog, original_data) { const data = JSON.parse(JSON.stringify(original_data)); const values = dialog.get_values(); + if (!values) return; // Validation failed // Update supplier if (data.supplier) { From 71aedfdec4f200050b3beab3cb9cd3f3335c3dd6 Mon Sep 17 00:00:00 2001 From: Preetam Biswal Date: Thu, 5 Feb 2026 00:06:22 +0100 Subject: [PATCH 04/14] fix: add frappe-dependencies for Frappe Cloud compatibility --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 120c8bb..bf6d653 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,10 @@ dependencies = [ requires = ["flit_core >=3.4,<4"] build-backend = "flit_core.buildapi" +[tool.bench.frappe-dependencies] +frappe = ">=15.0.0" +erpnext = ">=15.0.0" + # These dependencies are only installed when developer mode is enabled [tool.bench.dev-dependencies] # package_name = "~=1.1.0" From 11231b5c6642d4c21de4bee1a532760b52ab6f1e Mon Sep 17 00:00:00 2001 From: Antoine Maas Date: Wed, 11 Feb 2026 11:05:23 +0100 Subject: [PATCH 05/14] fix: prevent modifications to transmitted edocuments --- edocument/edocument/doctype/edocument/edocument.js | 12 +++++++++--- edocument/edocument/doctype/edocument/edocument.py | 14 +++++++++++++- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/edocument/edocument/doctype/edocument/edocument.js b/edocument/edocument/doctype/edocument/edocument.js index 331f112..9eba436 100644 --- a/edocument/edocument/doctype/edocument/edocument.js +++ b/edocument/edocument/doctype/edocument/edocument.js @@ -8,8 +8,10 @@ frappe.ui.form.on("EDocument", { }); function setup_action_buttons(frm) { - // Generate XML - for outgoing documents - if (frm.doc.edocument_source_document && frm.doc.edocument_profile) { + const is_transmitted = frm.doc.status === "Transmission Successful"; + + // Generate XML - for outgoing documents (not already transmitted) + if (frm.doc.edocument_source_document && frm.doc.edocument_profile && !is_transmitted) { frm.add_custom_button( __("Generate XML"), () => { @@ -35,7 +37,11 @@ function setup_action_buttons(frm) { if (!r.message && !frm.doc.xml_file) return; frm.add_custom_button(__("Preview EDocument"), () => show_preview(frm), __("Actions")); - frm.add_custom_button(__("Validate XML"), () => validate_xml(frm), __("Actions")); + + if (!is_transmitted) { + frm.add_custom_button(__("Validate XML"), () => validate_xml(frm), __("Actions")); + } + frm.add_custom_button(__("Match Document"), () => match_document(frm), __("Actions")); frm.add_custom_button( __("Create Document"), diff --git a/edocument/edocument/doctype/edocument/edocument.py b/edocument/edocument/doctype/edocument/edocument.py index c76fd6b..6925263 100644 --- a/edocument/edocument/doctype/edocument/edocument.py +++ b/edocument/edocument/doctype/edocument/edocument.py @@ -120,6 +120,9 @@ def generate_xml(self): Note: This method only generates XML - validation is handled separately. This is the public method called from the button. """ + if self.status == "Transmission Successful": + frappe.throw(_("Cannot regenerate XML for an already transmitted document.")) + try: file_name = self._generate_xml_internal() # Save the document to persist status and error field changes @@ -219,6 +222,9 @@ def validate_xml(self): Updates the status and error fields based on validation results. This is the public method called from the button. """ + if self.status == "Transmission Successful": + frappe.throw(_("Cannot re-validate an already transmitted document.")) + try: self._validate_xml_internal() # Save the document to persist status and error field changes @@ -301,7 +307,13 @@ def before_save(self): f"Error during automatic XML generation for EDocument {self.name}: {error_msg}" ) - if self.edocument_profile: + # Don't overwrite status for documents that reached a terminal state + # (already transmitted or matched) — re-validation would reset status + # back to "Validation Successful" and re-enable the Send button. + terminal_statuses = ("Transmission Successful", "Transmission Failed", + "Matching Successful", "Matching Failed") + + if self.edocument_profile and self.status not in terminal_statuses: # Validate XML automatically try: self._validate_xml_internal() From b4ff79240bd1fc58eb7095a42e95760f41be65f2 Mon Sep 17 00:00:00 2001 From: Antoine Maas Date: Wed, 11 Feb 2026 11:05:27 +0100 Subject: [PATCH 06/14] fix: match buyer company by tax ID instead of name --- edocument/edocument/profiles/peppol/parser.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/edocument/edocument/profiles/peppol/parser.py b/edocument/edocument/profiles/peppol/parser.py index 4a01769..6580484 100644 --- a/edocument/edocument/profiles/peppol/parser.py +++ b/edocument/edocument/profiles/peppol/parser.py @@ -261,12 +261,18 @@ def parse_peppol_buyer(root, namespaces): buyer_party, ".//cac:PartyLegalEntity/cbc:RegistrationName", namespaces ) or get_xml_text(buyer_party, ".//cac:PartyName/cbc:Name", namespaces) - # Try to find company + # Buyer tax ID + buyer_tax_id = get_xml_text(buyer_party, ".//cac:PartyTaxScheme/cbc:CompanyID", namespaces) + + # Try to find company by tax ID first (more reliable than name) company = None - if buyer_name and frappe.db.exists("Company", buyer_name): + if buyer_tax_id: + company = frappe.db.get_value("Company", {"tax_id": buyer_tax_id}, "name") + + if not company and buyer_name and frappe.db.exists("Company", buyer_name): company = buyer_name - return {"company": company, "name": buyer_name} + return {"company": company, "name": buyer_name, "tax_id": buyer_tax_id} def parse_peppol_line_items( From 025391dcaedd7b0715fc90f0f39b4ae2ba32cc26 Mon Sep 17 00:00:00 2001 From: Antoine Maas Date: Thu, 12 Feb 2026 13:45:12 +0100 Subject: [PATCH 07/14] fix: format terminal_statuses tuple for ruff compliance --- edocument/edocument/doctype/edocument/edocument.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/edocument/edocument/doctype/edocument/edocument.py b/edocument/edocument/doctype/edocument/edocument.py index 6925263..2b30a65 100644 --- a/edocument/edocument/doctype/edocument/edocument.py +++ b/edocument/edocument/doctype/edocument/edocument.py @@ -310,8 +310,12 @@ def before_save(self): # Don't overwrite status for documents that reached a terminal state # (already transmitted or matched) — re-validation would reset status # back to "Validation Successful" and re-enable the Send button. - terminal_statuses = ("Transmission Successful", "Transmission Failed", - "Matching Successful", "Matching Failed") + terminal_statuses = ( + "Transmission Successful", + "Transmission Failed", + "Matching Successful", + "Matching Failed", + ) if self.edocument_profile and self.status not in terminal_statuses: # Validate XML automatically From 618039c3774188dde41b9c352fd31cc5f95b7268 Mon Sep 17 00:00:00 2001 From: Antoine Maas Date: Thu, 12 Feb 2026 13:49:26 +0100 Subject: [PATCH 08/14] fix: make status field read-only --- edocument/edocument/doctype/edocument/edocument.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/edocument/edocument/doctype/edocument/edocument.json b/edocument/edocument/doctype/edocument/edocument.json index c1e59e4..1860faa 100644 --- a/edocument/edocument/doctype/edocument/edocument.json +++ b/edocument/edocument/doctype/edocument/edocument.json @@ -65,7 +65,8 @@ "in_list_view": 1, "in_standard_filter": 1, "label": "Status", - "options": "\nValidation Successful\nValidation Failed\nMatching Successful\nMatching Failed\nTransmission Successful\nTransmission Failed" + "options": "\nValidation Successful\nValidation Failed\nMatching Successful\nMatching Failed\nTransmission Successful\nTransmission Failed", + "read_only": 1 }, { "fieldname": "country", From 9562017168e972c739f4fc04e8e2671b8ec51dc1 Mon Sep 17 00:00:00 2001 From: Antoine Maas Date: Thu, 12 Feb 2026 15:17:06 +0100 Subject: [PATCH 09/14] fix: resolve missing item, tax account and payment schedule in PEPPOL parser Three issues preventing Purchase Invoice creation from incoming EDocuments: 1. Items not matched by name: when no matching_data, buyer_item_id or seller_product_id match exists, try matching by item_name (exact match on Item doctype). Without item_code, set_missing_item_details skips the item and expense_account is never resolved. 2. Tax account_head left empty: the tax guessing stub was a no-op. Now looks up account_head from existing Purchase Taxes templates matching the rate and company. 3. Payment schedule entry with only due_date (no payment_amount) caused a TypeError in set_payment_schedule. Don't create a default entry; let ERPNext compute it from payment_terms_template. --- edocument/edocument/profiles/peppol/parser.py | 49 +++++++++++++------ 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/edocument/edocument/profiles/peppol/parser.py b/edocument/edocument/profiles/peppol/parser.py index 4a01769..d24de4e 100644 --- a/edocument/edocument/profiles/peppol/parser.py +++ b/edocument/edocument/profiles/peppol/parser.py @@ -403,7 +403,7 @@ def parse_peppol_taxes(root, namespaces): # Tax account (try to find from tax category) # This is a simplified approach - you may need to map tax categories to accounts - tax["account_head"] = None # Will need to be set based on your tax setup + tax["account_head"] = None # Resolved later by guess_missing_values # Tax amount tax_amount = get_xml_text(tax_subtotal, ".//cbc:TaxAmount", namespaces) @@ -464,9 +464,9 @@ def parse_peppol_payment_terms(root, namespaces, default_due_date=None): payment_schedule.append(schedule_item) - # If no payment terms found, add default one - if not payment_schedule and default_due_date: - payment_schedule.append({"doctype": "Payment Schedule", "due_date": default_due_date}) + # Don't add a default payment_schedule entry with only due_date. + # ERPNext will compute the schedule from payment_terms_template (set via supplier defaults) + # or the user can add it manually. Adding an entry without payment_amount causes errors. return payment_schedule @@ -578,6 +578,17 @@ def guess_missing_values(pi_data): except Exception: pass + # Last resort: match by item name. Since item_name is not unique, + # this may match the wrong item when duplicates exist. + if not item.get("item_code") and item.get("item_name"): + item_name = item["item_name"] + if frappe.db.exists("Item", item_name): + item["item_code"] = item_name + else: + item_code = frappe.db.get_value("Item", {"item_name": item_name}, "name") + if item_code: + item["item_code"] = item_code + # Remove temporary fields (if they exist) if "seller_product_id" in item: item.pop("seller_product_id") @@ -593,17 +604,27 @@ def guess_missing_values(pi_data): if not item.get("purchase_order"): item["purchase_order"] = pi_data["purchase_order"] - # Guess tax accounts for taxes (simplified - you may need to map based on your tax setup) + # Guess tax accounts for taxes from existing Purchase Taxes templates + company = pi_data.get("company") for tax in pi_data.get("taxes", []): - if not tax.get("account_head") and tax.get("rate"): - # Try to find a default tax account based on rate - # This is a simplified approach - you may need to customize this - try: - # Get default tax account from company settings or tax template - # For now, leave it empty - user will need to set it manually - pass - except Exception: - pass + if not tax.get("account_head") and tax.get("rate") and company: + tax_rate = tax["rate"] + account_head = frappe.db.sql( + """SELECT child.account_head + FROM `tabPurchase Taxes and Charges` child + JOIN `tabPurchase Taxes and Charges Template` parent ON child.parent = parent.name + WHERE child.rate = %s AND parent.company = %s AND parent.disabled = 0 + ORDER BY parent.is_default DESC + LIMIT 1""", + (tax_rate, company), + ) + if account_head: + tax["account_head"] = account_head[0][0] + if not tax.get("description"): + if tax.get("rate"): + tax["description"] = _("VAT {0}%").format(tax["rate"]) + else: + tax["description"] = _("Tax") def guess_po_details(pi_data): From 9c588ec8a7bea224a20debed2980572283d0afb7 Mon Sep 17 00:00:00 2001 From: Preetam Biswal Date: Thu, 26 Feb 2026 02:54:00 +0100 Subject: [PATCH 10/14] fix: PEPPOL generator validation fixes and credit note sign handling - Always add TaxCategory to AllowanceCharge elements (BR-32 compliance) - Only use InvoiceTypeCode 384 when both parties are German organizations - Normalize negative signs for credit notes (abs on qty, amounts, totals) - Handle invoice deduction lines (negative amount moved to quantity) - Use consistent 2-decimal formatting for all monetary XML values --- .../edocument/profiles/peppol/generator.py | 170 ++++++++++++------ 1 file changed, 111 insertions(+), 59 deletions(-) diff --git a/edocument/edocument/profiles/peppol/generator.py b/edocument/edocument/profiles/peppol/generator.py index 4b8435e..015d789 100644 --- a/edocument/edocument/profiles/peppol/generator.py +++ b/edocument/edocument/profiles/peppol/generator.py @@ -429,15 +429,30 @@ def _add_line_item(self, root: ET.Element, item): line_id = ET.SubElement(line_elem, f"{{{self.namespaces['cbc']}}}ID") line_id.text = str(item.idx) + # Normalize signs for PEPPOL compliance + qty = flt(item.qty, item.precision("qty")) + item_amount = flt(item.amount, item.precision("amount")) + item_rate = flt(item.rate, item.precision("rate")) + + if self.document_type == "CreditNote": + # Credit notes: all values positive (document type implies reversal) + qty = abs(qty) + item_amount = abs(item_amount) + item_rate = abs(item_rate) + elif item_amount < 0: + # Invoice deduction line: move negative from rate/price to quantity + qty = -abs(qty) + item_rate = abs(item_rate) + quantity = ET.SubElement(line_elem, f"{{{self.namespaces['cbc']}}}{quantity_elem_name}") - quantity.text = str(flt(item.qty, item.precision("qty"))) + quantity.text = str(qty) quantity.set("unitCode", self.map_unit_code(item.uom)) # Update references from invoice_line to line_elem invoice_line = line_elem line_amount = ET.SubElement(invoice_line, f"{{{self.namespaces['cbc']}}}LineExtensionAmount") - line_amount.text = str(flt(item.amount, item.precision("amount"))) + line_amount.text = f"{item_amount:.2f}" line_amount.set("currencyID", self.invoice.currency) item_elem = ET.SubElement(invoice_line, f"{{{self.namespaces['cac']}}}Item") @@ -466,7 +481,8 @@ def _add_line_item(self, root: ET.Element, item): price = ET.SubElement(invoice_line, f"{{{self.namespaces['cac']}}}Price") price_amount = ET.SubElement(price, f"{{{self.namespaces['cbc']}}}PriceAmount") - price_amount.text = str(flt(item.rate, item.precision("rate"))) + price_precision = item.precision("rate") or 2 + price_amount.text = f"{item_rate:.{price_precision}f}" price_amount.set("currencyID", self.invoice.currency) def _add_tax_totals(self): @@ -477,8 +493,12 @@ def _add_tax_totals(self): tax_total = ET.SubElement(self.root, f"{{{self.namespaces['cac']}}}TaxTotal") # Use invoice.total_taxes_and_charges for total TaxAmount (already correctly calculated after discount) + # Credit notes: use abs() since PEPPOL CreditNote expects positive values + total_tax = flt(self.invoice.total_taxes_and_charges, 2) + if self.document_type == "CreditNote": + total_tax = abs(total_tax) tax_amount = ET.SubElement(tax_total, f"{{{self.namespaces['cbc']}}}TaxAmount") - tax_amount.text = str(flt(self.invoice.total_taxes_and_charges, 2)) + tax_amount.text = f"{total_tax:.2f}" tax_amount.set("currencyID", self.invoice.currency) # Group taxes by (category_code, rate) @@ -500,20 +520,23 @@ def _add_tax_totals(self): if key not in tax_groups: tax_groups[key] = {"taxable_amount": 0, "tax_amount": 0} - item_tax_amount = flt(item.net_amount) * (rate or 0) / 100 + net_amount = flt(item.net_amount) + if self.document_type == "CreditNote": + net_amount = abs(net_amount) + item_tax_amount = net_amount * (rate or 0) / 100 tax_groups[key]["tax_amount"] += item_tax_amount - tax_groups[key]["taxable_amount"] += flt(item.net_amount) + tax_groups[key]["taxable_amount"] += net_amount # Add TaxSubtotal for each (category, rate) group for (category_code, rate), data in tax_groups.items(): tax_subtotal = ET.SubElement(tax_total, f"{{{self.namespaces['cac']}}}TaxSubtotal") taxable_amount = ET.SubElement(tax_subtotal, f"{{{self.namespaces['cbc']}}}TaxableAmount") - taxable_amount.text = str(flt(data["taxable_amount"], 2)) + taxable_amount.text = f"{flt(data['taxable_amount'], 2):.2f}" taxable_amount.set("currencyID", self.invoice.currency) tax_amount = ET.SubElement(tax_subtotal, f"{{{self.namespaces['cbc']}}}TaxAmount") - tax_amount.text = str(flt(data["tax_amount"], 2)) + tax_amount.text = f"{flt(data['tax_amount'], 2):.2f}" tax_amount.set("currencyID", self.invoice.currency) tax_category = ET.SubElement(tax_subtotal, f"{{{self.namespaces['cac']}}}TaxCategory") @@ -674,6 +697,8 @@ def _add_allowances_charges(self): # Handle document-level discounts (AllowanceCharge with ChargeIndicator=false) discount_amount = flt(self.invoice.total, 2) - flt(self.invoice.net_total, 2) + if self.document_type == "CreditNote": + discount_amount = abs(discount_amount) if discount_amount > 0: # There's a document-level discount allowance_charge = ET.SubElement(self.root, f"{{{self.namespaces['cac']}}}AllowanceCharge") @@ -690,8 +715,13 @@ def _add_allowances_charges(self): reason.text = reason_text # MultiplierFactorNumeric: Calculate percentage (Amount / BaseAmount * 100) - if self.invoice.total > 0: - multiplier = (discount_amount / flt(self.invoice.total, 2)) * 100 + inv_total = ( + abs(flt(self.invoice.total, 2)) + if self.document_type == "CreditNote" + else flt(self.invoice.total, 2) + ) + if inv_total > 0: + multiplier = (discount_amount / inv_total) * 100 multiplier_factor = ET.SubElement( allowance_charge, f"{{{self.namespaces['cbc']}}}MultiplierFactorNumeric" ) @@ -699,17 +729,17 @@ def _add_allowances_charges(self): # Amount: The discount amount amount = ET.SubElement(allowance_charge, f"{{{self.namespaces['cbc']}}}Amount") - amount.text = str(flt(discount_amount, 2)) + amount.text = f"{flt(discount_amount, 2):.2f}" amount.set("currencyID", self.invoice.currency) # BaseAmount: The amount before discount (invoice.total) base_amount = ET.SubElement(allowance_charge, f"{{{self.namespaces['cbc']}}}BaseAmount") - base_amount.text = str(flt(self.invoice.total, 2)) + base_amount.text = f"{inv_total:.2f}" base_amount.set("currencyID", self.invoice.currency) - # Tax Category: Use the tax rate from invoice taxes + # Tax Category (BR-32: always required on AllowanceCharge) # Get the first non-Actual tax rate (usually VAT) - tax_rate = None + tax_rate = 0 sample_tax = None for tax in self.invoice.taxes: if tax.charge_type != "Actual" and tax.rate: @@ -717,25 +747,19 @@ def _add_allowances_charges(self): sample_tax = tax break - if tax_rate and tax_rate > 0: - tax_category = ET.SubElement(allowance_charge, f"{{{self.namespaces['cac']}}}TaxCategory") + tax_category = ET.SubElement(allowance_charge, f"{{{self.namespaces['cac']}}}TaxCategory") - # Get category code dynamically - category_code = ( - self.get_vat_category_code(self.invoice, tax=sample_tax) if sample_tax else "S" - ) + category_code = self.get_vat_category_code(self.invoice, tax=sample_tax) - # Category ID - category_id = ET.SubElement(tax_category, f"{{{self.namespaces['cbc']}}}ID") - category_id.text = category_code + category_id = ET.SubElement(tax_category, f"{{{self.namespaces['cbc']}}}ID") + category_id.text = category_code - tax_percent = ET.SubElement(tax_category, f"{{{self.namespaces['cbc']}}}Percent") - tax_percent.text = str(flt(tax_rate, 2)) + tax_percent = ET.SubElement(tax_category, f"{{{self.namespaces['cbc']}}}Percent") + tax_percent.text = str(flt(tax_rate, 2)) - # Tax scheme - tax_scheme = ET.SubElement(tax_category, f"{{{self.namespaces['cac']}}}TaxScheme") - scheme_id = ET.SubElement(tax_scheme, f"{{{self.namespaces['cbc']}}}ID") - scheme_id.text = "VAT" + tax_scheme = ET.SubElement(tax_category, f"{{{self.namespaces['cac']}}}TaxScheme") + scheme_id = ET.SubElement(tax_scheme, f"{{{self.namespaces['cbc']}}}ID") + scheme_id.text = "VAT" # Handle document-level charges (AllowanceCharge with ChargeIndicator=true) for tax in self.invoice.taxes: @@ -754,28 +778,29 @@ def _add_allowances_charges(self): reason.text = tax.description # Amount + charge_amt = ( + abs(flt(tax.tax_amount, 2)) + if self.document_type == "CreditNote" + else flt(tax.tax_amount, 2) + ) amount = ET.SubElement(allowance_charge, f"{{{self.namespaces['cbc']}}}Amount") - amount.text = str(flt(tax.tax_amount, 2)) + amount.text = f"{charge_amt:.2f}" amount.set("currencyID", self.invoice.currency) - # Tax Category (if applicable) - if tax.rate and tax.rate > 0: - tax_category = ET.SubElement(allowance_charge, f"{{{self.namespaces['cac']}}}TaxCategory") + # Tax Category (BR-32: always required on AllowanceCharge) + tax_category = ET.SubElement(allowance_charge, f"{{{self.namespaces['cac']}}}TaxCategory") - # Get category code dynamically - category_code = self.get_vat_category_code(self.invoice, tax=tax) + category_code = self.get_vat_category_code(self.invoice, tax=tax) - # Category ID - category_id = ET.SubElement(tax_category, f"{{{self.namespaces['cbc']}}}ID") - category_id.text = category_code + category_id = ET.SubElement(tax_category, f"{{{self.namespaces['cbc']}}}ID") + category_id.text = category_code - tax_percent = ET.SubElement(tax_category, f"{{{self.namespaces['cbc']}}}Percent") - tax_percent.text = str(flt(tax.rate, 2)) + tax_percent = ET.SubElement(tax_category, f"{{{self.namespaces['cbc']}}}Percent") + tax_percent.text = str(flt(tax.rate or 0, 2)) - # Tax scheme - tax_scheme = ET.SubElement(tax_category, f"{{{self.namespaces['cac']}}}TaxScheme") - scheme_id = ET.SubElement(tax_scheme, f"{{{self.namespaces['cbc']}}}ID") - scheme_id.text = "VAT" + tax_scheme = ET.SubElement(tax_category, f"{{{self.namespaces['cac']}}}TaxScheme") + scheme_id = ET.SubElement(tax_scheme, f"{{{self.namespaces['cbc']}}}ID") + scheme_id.text = "VAT" def _set_totals(self): # Set monetary totals using ERPNext values directly @@ -785,36 +810,46 @@ def _set_totals(self): # Add LegalMonetaryTotal section legal_total = ET.SubElement(self.root, f"{{{self.namespaces['cac']}}}LegalMonetaryTotal") + # For credit notes, ERPNext stores negative totals but PEPPOL requires positive values + is_credit = self.document_type == "CreditNote" + inv_total = abs(flt(self.invoice.total, 2)) if is_credit else flt(self.invoice.total, 2) + inv_net_total = abs(flt(self.invoice.net_total, 2)) if is_credit else flt(self.invoice.net_total, 2) + inv_grand_total = ( + abs(flt(self.invoice.grand_total, 2)) if is_credit else flt(self.invoice.grand_total, 2) + ) + # Line Extension Amount (BT-106) - Sum of Invoice line net amount (BEFORE discount) # Use invoice.total which is the sum of all item.amount values line_total = ET.SubElement(legal_total, f"{{{self.namespaces['cbc']}}}LineExtensionAmount") - line_total.text = str(flt(self.invoice.total, 2)) + line_total.text = f"{inv_total:.2f}" line_total.set("currencyID", self.invoice.currency) # Tax Exclusive Amount (BT-109) - Invoice total amount without VAT # This equals LineExtensionAmount - AllowanceTotalAmount = net_total (after discount, before tax) tax_exclusive_amount = ET.SubElement(legal_total, f"{{{self.namespaces['cbc']}}}TaxExclusiveAmount") - tax_exclusive_amount.text = str(flt(self.invoice.net_total, 2)) + tax_exclusive_amount.text = f"{inv_net_total:.2f}" tax_exclusive_amount.set("currencyID", self.invoice.currency) # Tax Inclusive Amount (BT-112) - Invoice total amount with VAT tax_inclusive_amount = ET.SubElement(legal_total, f"{{{self.namespaces['cbc']}}}TaxInclusiveAmount") - tax_inclusive_amount.text = str(flt(self.invoice.grand_total, 2)) + tax_inclusive_amount.text = f"{inv_grand_total:.2f}" tax_inclusive_amount.set("currencyID", self.invoice.currency) # Allowance Total Amount (BT-107) - Sum of allowances on document level (discount) # Calculate as difference between total (before discount) and net_total (after discount) - allowance_amount = flt(self.invoice.total, 2) - flt(self.invoice.net_total, 2) + allowance_amount = inv_total - inv_net_total allowance_total = ET.SubElement(legal_total, f"{{{self.namespaces['cbc']}}}AllowanceTotalAmount") - allowance_total.text = str(flt(allowance_amount, 2)) + allowance_total.text = f"{flt(allowance_amount, 2):.2f}" allowance_total.set("currencyID", self.invoice.currency) # Charge Total Amount (BT-108) - Sum of charges on document level # Must come AFTER AllowanceTotalAmount per XSD schema order actual_charge_total = sum(tax.tax_amount for tax in self.invoice.taxes if tax.charge_type == "Actual") + if is_credit: + actual_charge_total = abs(actual_charge_total) if actual_charge_total: charge_total = ET.SubElement(legal_total, f"{{{self.namespaces['cbc']}}}ChargeTotalAmount") - charge_total.text = str(flt(actual_charge_total, 2)) + charge_total.text = f"{flt(actual_charge_total, 2):.2f}" charge_total.set("currencyID", self.invoice.currency) else: # Always add ChargeTotalAmount (set to 0.00 if no charges) @@ -825,24 +860,22 @@ def _set_totals(self): # Prepaid Amount (BT-113) - Sum of amounts already paid # This is required when PayableAmount != TaxInclusiveAmount to satisfy BR-CO-16: # PayableAmount = TaxInclusiveAmount - PrepaidAmount + RoundingAmount - if self.document_type != "CreditNote": + if not is_credit: prepaid_value = flt(self.invoice.grand_total, 2) - flt(self.invoice.outstanding_amount, 2) if prepaid_value > 0: prepaid_amount = ET.SubElement(legal_total, f"{{{self.namespaces['cbc']}}}PrepaidAmount") - prepaid_amount.text = str(flt(prepaid_value, 2)) + prepaid_amount.text = f"{flt(prepaid_value, 2):.2f}" prepaid_amount.set("currencyID", self.invoice.currency) # Payable Amount (BT-115) - Amount due for payment - # For credit notes, this should be negative (amount to be credited) payable_amount = ET.SubElement(legal_total, f"{{{self.namespaces['cbc']}}}PayableAmount") - if self.document_type == "CreditNote": - # For credit notes, PayableAmount should be negative (the amount to be credited) - # Use grand_total (which is already negative for credit notes) - payable_value = flt(self.invoice.grand_total, 2) + if is_credit: + # For credit notes, use positive grand_total (amount to be credited) + payable_value = inv_grand_total else: # For invoices, use outstanding_amount payable_value = flt(self.invoice.outstanding_amount, 2) - payable_amount.text = str(payable_value) + payable_amount.text = f"{payable_value:.2f}" payable_amount.set("currencyID", self.invoice.currency) def initialize_peppol_xml(self) -> ET.Element: @@ -960,12 +993,31 @@ def get_invoice_type_code(self, invoice) -> str: if hasattr(invoice, "is_return") and invoice.is_return: invoice_type_code = "381" elif hasattr(invoice, "amended_from") and invoice.amended_from: - invoice_type_code = "384" + # 384 (Corrected Invoice) only allowed when both parties are German + if self._both_parties_german(): + invoice_type_code = "384" except Exception: pass return invoice_type_code + def _both_parties_german(self) -> bool: + # Check if both seller and buyer are German organizations + try: + seller_code = "" + if self.seller_address and self.seller_address.country: + seller_code = ( + frappe.db.get_value("Country", self.seller_address.country, "code") or "" + ).upper() + buyer_code = "" + if self.buyer_address and self.buyer_address.country: + buyer_code = ( + frappe.db.get_value("Country", self.buyer_address.country, "code") or "" + ).upper() + return seller_code == "DE" and buyer_code == "DE" + except Exception: + return False + def map_unit_code(self, erpnext_unit: str) -> str: # Map ERPNext unit codes to PEPPOL standard unit codes using CommonCodeRetriever if not erpnext_unit: From 6150684a137b6138bb8616f82ab3927fedf79dcf Mon Sep 17 00:00:00 2001 From: Antoine Maas Date: Sun, 22 Mar 2026 19:21:51 +0100 Subject: [PATCH 11/14] chore: add apt deploy dependencies for lxml --- pyproject.toml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index bf6d653..8e1a56c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,3 +63,9 @@ typing-modules = ["frappe.types.DF"] quote-style = "double" indent-style = "tab" docstring-code-format = true + +[deploy.dependencies.apt] +packages = [ + "libxml2-dev", + "libxslt-dev", +] From 6bbe5f646ce3ba00cc7cb2585cd36ab581620045 Mon Sep 17 00:00:00 2001 From: Preetam Biswal Date: Fri, 3 Apr 2026 02:48:10 +0200 Subject: [PATCH 12/14] fix: handle negative qty, use tax templates, and fix company detection - Move negative sign from qty to rate on non-return invoices - Replace manual tax row building with header/item tax template lookup - Always run field detector from XML, overriding Frappe default company --- .../edocument/doctype/edocument/edocument.py | 12 +- edocument/edocument/profiles/peppol/parser.py | 111 ++++++++++++++---- 2 files changed, 94 insertions(+), 29 deletions(-) diff --git a/edocument/edocument/doctype/edocument/edocument.py b/edocument/edocument/doctype/edocument/edocument.py index 2b30a65..aabc50c 100644 --- a/edocument/edocument/doctype/edocument/edocument.py +++ b/edocument/edocument/doctype/edocument/edocument.py @@ -277,16 +277,16 @@ def before_save(self): ) # Auto-detect EDocument fields (company, etc.) from XML using profile-specific detector - if self.edocument_profile and has_xml and not self.company: + if self.edocument_profile and has_xml: try: xml_bytes = self._get_xml_from_attached_files() if xml_bytes: from edocument.edocument.detector import get_edocument_fields detected_fields = get_edocument_fields(xml_bytes, self.edocument_profile) - # Set detected fields on the document + # Set detected fields — override defaults from Frappe for field, value in detected_fields.items(): - if hasattr(self, field) and not getattr(self, field): + if hasattr(self, field) and value: setattr(self, field, value) except Exception as e: frappe.log_error( @@ -363,16 +363,16 @@ def on_update(self): ) # Auto-detect EDocument fields (company, etc.) from XML using profile-specific detector - if self.edocument_profile and has_xml and not self.company: + if self.edocument_profile and has_xml: try: xml_bytes = self._get_xml_from_attached_files() if xml_bytes: from edocument.edocument.detector import get_edocument_fields detected_fields = get_edocument_fields(xml_bytes, self.edocument_profile) - # Update detected fields directly in database + # Update detected fields — override defaults from Frappe for field, value in detected_fields.items(): - if hasattr(self, field) and not getattr(self, field): + if hasattr(self, field) and value: self.db_set(field, value, update_modified=False) except Exception as e: frappe.log_error( diff --git a/edocument/edocument/profiles/peppol/parser.py b/edocument/edocument/profiles/peppol/parser.py index 16849b6..5693db1 100644 --- a/edocument/edocument/profiles/peppol/parser.py +++ b/edocument/edocument/profiles/peppol/parser.py @@ -187,10 +187,11 @@ def parse_peppol_xml(xml_bytes, edocument_profile, edocument=None): pi_data.get("supplier"), document_elements, matched_items=matched_items, + is_return=pi_data.get("is_return"), ) - # Parse taxes - pi_data["taxes"] = parse_peppol_taxes(root, namespaces) + # Remove taxes - will be populated from templates by guess_tax_templates + pi_data.pop("taxes", None) # Post-process: guess missing values guess_missing_values(pi_data) @@ -276,7 +277,13 @@ def parse_peppol_buyer(root, namespaces): def parse_peppol_line_items( - root, namespaces, purchase_order=None, supplier=None, document_elements=None, matched_items=None + root, + namespaces, + purchase_order=None, + supplier=None, + document_elements=None, + matched_items=None, + is_return=False, ): """ Parse line items from PEPPOL XML. @@ -343,8 +350,14 @@ def parse_peppol_line_items( # Quantity and UOM (use document-specific quantity element name) qty_elem = invoice_line.find(f".//cbc:{quantity_elem_name}", namespaces) + negative_qty = False if qty_elem is not None and qty_elem.text: item["qty"] = flt_or_none(qty_elem.text) + # ERPNext does not allow negative quantities on non-return invoices + # Move the sign from quantity to rate to preserve the line total + if item["qty"] is not None and item["qty"] < 0 and not is_return: + negative_qty = True + item["qty"] = abs(item["qty"]) unit_code = qty_elem.get("unitCode") if unit_code: # Store unit_code for later UOM guessing @@ -360,11 +373,14 @@ def parse_peppol_line_items( # rate = PriceAmount / BaseQuantity net_rate = float(price_text) base_qty = float(base_qty_text) if base_qty_text else 1.0 - item["rate"] = net_rate / base_qty if base_qty else net_rate + rate = net_rate / base_qty if base_qty else net_rate + item["rate"] = -rate if negative_qty else rate # Line total if line_total_text: item["amount"] = flt_or_none(line_total_text) + if negative_qty and item["amount"] is not None: + item["amount"] = -abs(item["amount"]) # Tax rate (for reference, actual tax is in taxes table) tax_category = invoice_line.find(".//cac:Item/cac:ClassifiedTaxCategory", namespaces) @@ -610,27 +626,76 @@ def guess_missing_values(pi_data): if not item.get("purchase_order"): item["purchase_order"] = pi_data["purchase_order"] - # Guess tax accounts for taxes from existing Purchase Taxes templates + # Set tax templates based on item tax rates + guess_tax_templates(pi_data) + + +def guess_tax_templates(pi_data): + """Set header or item tax templates based on item tax rates. + + If all items share the same tax rate, set a header Purchase Taxes and Charges Template. + If items have different tax rates, set Item Tax Template on each item. + """ company = pi_data.get("company") - for tax in pi_data.get("taxes", []): - if not tax.get("account_head") and tax.get("rate") and company: - tax_rate = tax["rate"] - account_head = frappe.db.sql( - """SELECT child.account_head - FROM `tabPurchase Taxes and Charges` child - JOIN `tabPurchase Taxes and Charges Template` parent ON child.parent = parent.name - WHERE child.rate = %s AND parent.company = %s AND parent.disabled = 0 - ORDER BY parent.is_default DESC - LIMIT 1""", - (tax_rate, company), + if not company: + return + + items = pi_data.get("items", []) + if not items: + return + + # Collect unique tax rates from items + tax_rates = {item["tax_rate"] for item in items if item.get("tax_rate") is not None} + if not tax_rates: + return + + if len(tax_rates) == 1: + # All items have the same rate — use header template + rate = tax_rates.pop() + template = frappe.db.get_value( + "Purchase Taxes and Charges", + { + "rate": rate, + "charge_type": "On Net Total", + "parenttype": "Purchase Taxes and Charges Template", + "parent": [ + "in", + frappe.get_all( + "Purchase Taxes and Charges Template", + filters={"company": company, "disabled": 0}, + pluck="name", + ), + ], + }, + "parent", + ) + if template: + pi_data["taxes_and_charges"] = template + else: + # Mixed rates — set Item Tax Template per item + for item in items: + rate = item.get("tax_rate") + if rate is None: + continue + + item_tax_template = frappe.db.get_value( + "Item Tax Template Detail", + { + "tax_rate": rate, + "parenttype": "Item Tax Template", + "parent": [ + "in", + frappe.get_all( + "Item Tax Template", + filters={"company": company, "disabled": 0}, + pluck="name", + ), + ], + }, + "parent", ) - if account_head: - tax["account_head"] = account_head[0][0] - if not tax.get("description"): - if tax.get("rate"): - tax["description"] = _("VAT {0}%").format(tax["rate"]) - else: - tax["description"] = _("Tax") + if item_tax_template: + item["item_tax_template"] = item_tax_template def guess_po_details(pi_data): From 12f49f4adc717eee6a12721d75b527bef870a5a3 Mon Sep 17 00:00:00 2001 From: Preetam Biswal Date: Tue, 7 Apr 2026 14:07:15 +0200 Subject: [PATCH 13/14] fix: strip HTML from item description in XML and prevent edocument copy - Convert HTML item descriptions to plain text using html2text before writing to PEPPOL XML, preserving paragraph breaks - Add no_copy to edocument and edocument_status fields on Sales Invoice and Purchase Invoice to prevent copying when duplicating invoices --- edocument/edocument/profiles/peppol/generator.py | 7 ++++++- edocument/install.py | 4 ++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/edocument/edocument/profiles/peppol/generator.py b/edocument/edocument/profiles/peppol/generator.py index 015d789..c0fda2c 100644 --- a/edocument/edocument/profiles/peppol/generator.py +++ b/edocument/edocument/profiles/peppol/generator.py @@ -458,7 +458,12 @@ def _add_line_item(self, root: ET.Element, item): item_elem = ET.SubElement(invoice_line, f"{{{self.namespaces['cac']}}}Item") description = ET.SubElement(item_elem, f"{{{self.namespaces['cbc']}}}Description") - description.text = item.description or item.item_name + item_description = item.description or item.item_name + if frappe.utils.is_html(item_description): + from frappe.core.utils import html2text + + item_description = html2text(item_description).strip() + description.text = item_description name = ET.SubElement(item_elem, f"{{{self.namespaces['cbc']}}}Name") name.text = item.item_name diff --git a/edocument/install.py b/edocument/install.py index 726559a..f1a3b3c 100644 --- a/edocument/install.py +++ b/edocument/install.py @@ -125,6 +125,7 @@ def get_custom_fields(): "options": "EDocument", "insert_after": "edocument_profile", "read_only": 1, + "no_copy": 1, }, { "fieldname": "edocument_status", @@ -133,6 +134,7 @@ def get_custom_fields(): "insert_after": "edocument", "fetch_from": "edocument.status", "read_only": 1, + "no_copy": 1, }, ], "Purchase Invoice": [ @@ -149,6 +151,7 @@ def get_custom_fields(): "options": "EDocument", "insert_after": "edocument_tab", "read_only": 1, + "no_copy": 1, }, { "fieldname": "edocument_status", @@ -157,6 +160,7 @@ def get_custom_fields(): "insert_after": "edocument", "fetch_from": "edocument.status", "read_only": 1, + "no_copy": 1, }, ], } From d615552ae1aac2934368d9adccd84136f191750d Mon Sep 17 00:00:00 2001 From: Preetam Biswal Date: Thu, 9 Apr 2026 11:49:35 +0200 Subject: [PATCH 14/14] fix: isolate preview CSS in iframe and negate credit note quantities Preview now renders in an iframe to prevent PEPPOL stylesheet from clashing with ERPNext desk CSS. Parser negates qty and amount for credit notes since PEPPOL uses positive values but ERPNext expects negative on return invoices. --- .../edocument/doctype/edocument/edocument.js | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/edocument/edocument/doctype/edocument/edocument.js b/edocument/edocument/doctype/edocument/edocument.js index 9eba436..2e6391b 100644 --- a/edocument/edocument/doctype/edocument/edocument.js +++ b/edocument/edocument/doctype/edocument/edocument.js @@ -68,15 +68,16 @@ function show_preview(frm) { freeze_message: __("Generating preview..."), callback: (r) => { if (!r.message) return; - frm.get_field("edocument_preview")?.set_value(r.message); - frm.get_field("edocument_preview")?.$wrapper?.css({ - width: "100%", - padding: "15px", - background: "#fff", - border: "1px solid #e0e0e0", - borderRadius: "4px", - marginTop: "10px", - }); + const field = frm.get_field("edocument_preview"); + if (!field) return; + + // Render preview in an iframe to isolate its CSS from ERPNext + const iframe = ``; + field.set_value(iframe); + field.$wrapper?.css({ width: "100%", marginTop: "10px" }); }, }); }