From d11b6eb5c77adb5d985d5fb43b16c720d85c45e1 Mon Sep 17 00:00:00 2001 From: Preetam Biswal Date: Thu, 22 Jan 2026 21:28:04 +0100 Subject: [PATCH] feat: add intra-community invoice support --- README.md | 20 +++++ .../edocument/profiles/peppol/__init__.py | 21 +++++ .../edocument/profiles/peppol/generator.py | 83 +++++++++++++++++-- 3 files changed, 116 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index dcfb5b7..5bb69ff 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,26 @@ Most invoices use standard VAT rates (S) or zero-rated VAT (Z, automatically det For item-specific tax treatment, map codes to **Item**, **Item Tax Template** or **Account** instead of **Tax Category**. +### Intra-Community (IC) Invoices + +For sales to businesses in other EU countries with 0% VAT (intra-community supply): + +1. **Setup**: Create a **Tax Category** (e.g., "Intra-Community") and map it to code "K" in **Common Code**. + +2. **Automatic Handling**: When any invoice line uses category "K", the app automatically: + - Adds `TaxExemptionReasonCode` with value `VATEX-EU-IC` + - Adds `TaxExemptionReason` with text "Intra-community supply" + - Adds `Delivery` element with: + - `ActualDeliveryDate` (uses delivery date or posting date) + - Delivery country code (from shipping address or customer address) + +3. **PEPPOL Rules Satisfied**: + - **BR-IC-10**: Exemption reason code/text for IC supply + - **BR-IC-11**: Actual delivery date is required + - **BR-IC-12**: Delivery country code is required + +**Example**: A Dutch company sells goods to a German company. Set the Tax Category to "Intra-Community" (mapped to "K"). The e-document will include 0% VAT with IC exemption reason and delivery details proving goods were delivered to Germany. + ## How to Guide ### Master Data Configuration diff --git a/edocument/edocument/profiles/peppol/__init__.py b/edocument/edocument/profiles/peppol/__init__.py index 41cab5b..2331a2e 100644 --- a/edocument/edocument/profiles/peppol/__init__.py +++ b/edocument/edocument/profiles/peppol/__init__.py @@ -63,6 +63,27 @@ }, } +# VATEX Exemption Reason Codes +# Used for TaxExemptionReasonCode element in TaxCategory +# Reference: https://ec.europa.eu/digital-building-blocks/wikis/display/DIGITAL/Code+lists +VATEX_CODES = { + "K": "VATEX-EU-IC", # Intra-community supply + "AE": "VATEX-EU-AE", # Reverse charge + "O": "VATEX-EU-O", # Not subject to VAT + "E": "VATEX-EU-132", # Exempt from VAT (generic exemption) + "G": "VATEX-EU-G", # Export outside the EU +} + +# VATEX Exemption Reason Texts +# Used for TaxExemptionReason element in TaxCategory +VATEX_REASON_TEXTS = { + "K": "Intra-community supply", + "AE": "Reverse charge", + "O": "Not subject to VAT", + "E": "Exempt from VAT", + "G": "Export outside the EU", +} + # Global code retrievers for PEPPOL standardized codes (shared across generator and import) from edocument.edocument.common_codes import CommonCodeRetriever diff --git a/edocument/edocument/profiles/peppol/generator.py b/edocument/edocument/profiles/peppol/generator.py index df6cee1..4b8435e 100644 --- a/edocument/edocument/profiles/peppol/generator.py +++ b/edocument/edocument/profiles/peppol/generator.py @@ -19,6 +19,8 @@ PEPPOL_CUSTOMIZATION_ID, PEPPOL_PROFILE_ID, UBL_NAMESPACES, + VATEX_CODES, + VATEX_REASON_TEXTS, duty_tax_fee_category_codes, payment_means_codes, uom_codes, @@ -86,6 +88,19 @@ def _has_out_of_scope_items(self) -> bool: return True return False + def _is_intra_community_invoice(self) -> bool: + """Check if the invoice is an intra-community supply. + + An invoice is intra-community if any item has VAT category K. + + Returns: + bool: True if this is an intra-community invoice + """ + for item in self.invoice.items: + if self.get_vat_category_code(self.invoice, item=item) == "K": + return True + return False + def create_einvoice(self): # Create the PEPPOL XML document try: @@ -97,6 +112,7 @@ def create_einvoice(self): self._set_header() self._set_seller() self._set_buyer() + self._add_delivery() self._add_payment_means() self._add_allowances_charges() self._add_tax_totals() @@ -351,6 +367,48 @@ def _set_buyer(self): contact_email = ET.SubElement(contact, f"{{{self.namespaces['cbc']}}}ElectronicMail") contact_email.text = self.invoice.contact_email + def _add_delivery(self): + """Add Delivery element for intra-community and cross-border invoices. + + Required by: + - BR-IC-11: Intra-community supply must have ActualDeliveryDate or InvoicePeriod + - BR-IC-12: Intra-community supply must have Delivery country code + + For IC invoices, adds: + - ActualDeliveryDate (BT-72): Uses posting_date or delivery_date from invoice + - DeliveryLocation/Address/Country (BT-80): Country where goods were delivered + """ + if not hasattr(self, "root") or self.root is None: + return + + # Check if this is an intra-community invoice or has shipping address + is_ic = self._is_intra_community_invoice() + + # Only add Delivery for IC invoices or when shipping address is present + if not is_ic and not self.shipping_address: + return + + delivery = ET.SubElement(self.root, f"{{{self.namespaces['cac']}}}Delivery") + + # ActualDeliveryDate (BT-72) - Required for IC invoices (BR-IC-11) + # Use delivery_date if available, otherwise posting_date + delivery_date = getattr(self.invoice, "delivery_date", None) or self.invoice.posting_date + if delivery_date: + actual_delivery_date = ET.SubElement(delivery, f"{{{self.namespaces['cbc']}}}ActualDeliveryDate") + actual_delivery_date.text = self.format_date(delivery_date) + + # DeliveryLocation with Country (BT-80) - Required for IC invoices (BR-IC-12) + # Use shipping address if available, otherwise buyer address + delivery_address = self.shipping_address or self.buyer_address + if delivery_address and delivery_address.country: + delivery_location = ET.SubElement(delivery, f"{{{self.namespaces['cac']}}}DeliveryLocation") + address = ET.SubElement(delivery_location, f"{{{self.namespaces['cac']}}}Address") + country = ET.SubElement(address, f"{{{self.namespaces['cac']}}}Country") + country_code_elem = ET.SubElement(country, f"{{{self.namespaces['cbc']}}}IdentificationCode") + country_code_elem.text = ( + frappe.db.get_value("Country", delivery_address.country, "code") or "DE" + ).upper() + def _add_line_items(self): # Add invoice line items if not hasattr(self, "root") or self.root is None: @@ -469,6 +527,15 @@ def _add_tax_totals(self): tax_percent.text = str(flt(rate or 0, 2)) if category_code in ["E", "AE", "G", "O", "K"]: + # Add TaxExemptionReasonCode (VATEX code) + exemption_code = self._get_exemption_reason_code(category_code) + if exemption_code: + exemption_reason_code = ET.SubElement( + tax_category, f"{{{self.namespaces['cbc']}}}TaxExemptionReasonCode" + ) + exemption_reason_code.text = exemption_code + + # Add TaxExemptionReason (text description) exemption_text = self._get_exemption_reason_text(category_code) if exemption_text: exemption_reason = ET.SubElement( @@ -956,20 +1023,20 @@ def get_vat_category_code(self, invoice, item=None, tax=None) -> str: # Default to S (standard) for everything else return duty_tax_fee_category_codes.default_code or "S" + def _get_exemption_reason_code(self, category_code: str) -> str: + """Get VATEX exemption reason code for PEPPOL validation. + + Returns the standardized VATEX code for non-standard VAT categories. + """ + return VATEX_CODES.get(category_code, "") + def _get_exemption_reason_text(self, category_code: str) -> str: """Get exemption reason text for PEPPOL validation. Categories E, AE, G, O, K require either TaxExemptionReasonCode or TaxExemptionReason per PEPPOL BIS business rules (BR-E-10, BR-AE-10, BR-G-10, BR-O-10, BR-IC-10). """ - texts = { - "E": "Exempt from VAT", - "AE": "Reverse charge", - "G": "Export outside the EU", - "O": "Not subject to VAT", - "K": "Intra-community supply", - } - return texts.get(category_code, "") + return VATEX_REASON_TEXTS.get(category_code, "") def get_xml_bytes(self) -> bytes: # Return the XML as bytes