From 74e2c446e2ca58ee0ac98eb8b72ae29480c208d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C4=8Ciha=C5=99?= Date: Wed, 5 Nov 2025 12:47:34 +0100 Subject: [PATCH] feat: generate EN 16931 compatible invoices This is a proof of concept, I had to use several libraries to make it work. Outstanding issues: - The VAT validation fails because of https://github.com/zfutura/pycheval/issues/30 - We really need full validation to be part of the tests, needs to be investigated. - There are definitely issues with generated XML. --- .github/workflows/test.yml | 9 ++ requirements.txt | 2 + weblate_web/invoices/models.py | 163 ++++++++++++++++++++++++++++++++- weblate_web/invoices/tests.py | 22 +++++ weblate_web/pdf.py | 15 ++- 5 files changed, 209 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 89ff208834..185501673d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -47,6 +47,13 @@ jobs: run: | sudo apt update sudo apt install gettext + - name: Start validation service + env: + # renovate: datasource=github-releases depName=gflohr/e-invoice-eu-validator versioning=loose + VALIDATOR_VERSION: 2.16.4 + run: | + curl -L "https://github.com/gflohr/e-invoice-eu-validator/releases/download/v$VALIDATOR_VERSION/validator-$VALIDATOR_VERSION-jar-with-dependencies.jar" > /tmp/validator.jar + PORT=7070 java -jar /tmp/validator.jar & - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: @@ -63,6 +70,8 @@ jobs: run: ./manage.py collectstatic - name: Django checks run: ./manage.py check + env: + EINVOICE_VALIDATOR_URL: http://localhost:7070/ - name: Test with Django run: | pytest --junitxml=junit.xml weblate_web diff --git a/requirements.txt b/requirements.txt index e501955e5e..f3b91f0453 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,6 +11,7 @@ django-redis==6.0.0 django-stubs-ext==5.2.8 django-vies==6.2.2 djangosaml2==1.11.1 +drafthorse==2025.2.0 fiobank==4.0.0 hiredis==3.3.0 html2text==2025.4.15 @@ -20,6 +21,7 @@ mysqlclient==2.2.7 paramiko==4.0.0 Pillow==12.0.0 psycopg[binary]==3.3.2 +PyCheval==0.3.0 pyopenssl==25.3.0 python-dateutil==2.9.0.post0 pytz==2025.2 diff --git a/weblate_web/invoices/models.py b/weblate_web/invoices/models.py index 7053470a45..fb51f4ab92 100644 --- a/weblate_web/invoices/models.py +++ b/weblate_web/invoices/models.py @@ -41,6 +41,23 @@ from django.utils.timezone import now from django.utils.translation import gettext, override from lxml import etree +from pycheval import ( + BankAccount, + EN16931Invoice, + EN16931LineItem, + Money, + PaymentMeans, + PaymentTerms, + PostalAddress, + Tax, + TradeParty, + generate_xml, +) + +# TODO: consider https://pypi.org/project/pyfactx/ instead of pycheval +from pycheval.quantities import QuantityCode +from pycheval.type_codes import DocumentTypeCode, PaymentMeansCode, TaxCategoryCode +from weasyprint import Attachment from weblate_web.exchange_rates import ExchangeRates from weblate_web.pdf import render_pdf @@ -540,8 +557,14 @@ def xml_path(self) -> Path: """XML path object.""" return settings.INVOICES_PATH / self.get_filename("xml") + @property + def en_16931_xml_path(self) -> Path: + """XML path object.""" + return settings.INVOICES_PATH / self.get_filename("einvoice.xml") + def generate_files(self) -> None: self.generate_money_s3_xml() + self.generate_en_16931_xml() self.generate_pdf() self.sync_files() @@ -657,7 +680,7 @@ def add_amounts(root, in_czk: bool = False) -> None: add_element(adresa, "Ulice", self.customer.address) add_element(adresa, "Misto", self.customer.city) add_element(adresa, "PSC", self.customer.postcode) - add_element(adresa, "Stat", self.customer.country) + add_element(adresa, "Stat", self.customer.country.code) if self.customer.vat: add_element(prijemce, "PlatceDPH", "1") add_element(prijemce, "FyzOsoba", "0") @@ -690,6 +713,136 @@ def generate_money_s3_xml(self) -> None: settings.INVOICES_PATH.mkdir(exist_ok=True) self.save_invoice_xml(document, self.xml_path) + def get_en_16931_xml(self) -> EN16931Invoice: + type_code = DocumentTypeCode.INVOICING_DATA_SHEET + if self.kind == InvoiceKind.INVOICE: + type_code = DocumentTypeCode.INVOICE + elif self.kind == InvoiceKind.QUOTE: + type_code = DocumentTypeCode.VALIDATED_PRICED_TENDER + elif self.kind == InvoiceKind.PROFORMA: + type_code = DocumentTypeCode.PRO_FORMA_INVOICE + total_amount = Money(self.total_amount, self.get_currency_display()) + + tax_amount = Money(self.total_vat, self.get_currency_display()) + tax_basis_amount = Money(self.total_amount_no_vat, self.get_currency_display()) + + tax_category = ( + TaxCategoryCode.STANDARD_RATE + if self.vat_rate + else TaxCategoryCode.REVERSE_CHARGE + ) + tax = Tax( + category_code=tax_category, + calculated_amount=tax_amount, + basis_amount=tax_basis_amount, + rate_percent=self.vat_rate or None, + exemption_reason="Reverse charge" if not self.vat_rate else None, + ) + + line_items = [ + EN16931LineItem( + id=item.package.name if item.package else "ITEM", + name=item.description, + net_price=Money(item.unit_price, self.get_currency_display()), + billed_quantity=( + item.quantity, + QuantityCode.ONE, + ), # TODO: convert quantity_unit to QuantityCode + billed_total=Money(item.total_price, self.get_currency_display()), + tax_rate=self.vat_rate or None, + tax_category=tax_category, + billing_period=(item.start_date, item.end_date) + if item.has_date_range + else None, + ) + for item in self.all_items + ] + # TODO: There might be model for discount + if self.discount: + line_items.append( + EN16931LineItem( + id="DISCOUNT", + name=self.discount.description, + description=self.discount.display_percents, + net_price=Money(self.total_discount, self.get_currency_display()), + billed_quantity=(1, QuantityCode.ONE), + billed_total=Money( + self.total_discount, self.get_currency_display() + ), + tax_rate=self.vat_rate, + tax_category=tax_category, + ) + ) + + payment_means = [] + if self.prepaid: + prepaid_amount = total_amount + due_payable_amount = Money(Decimal(0), self.get_currency_display()) + payment_reference = None + payment_terms = None + else: + prepaid_amount = None + due_payable_amount = total_amount + payment_reference = self.number + if self.currency == Currency.EUR: + # TODO: support other currencies here + payment_means = [ + PaymentMeans( + type_code=PaymentMeansCode.BANK_PAYMENT, + payee_account=BankAccount( + name=self.bank_account.holder, + bank_id=self.bank_account.bic, + iban=self.bank_account.raw_iban, + ), + payee_bic=self.bank_account.bic, + ) + ] + payment_terms = PaymentTerms(due_date=self.due_date) + + return EN16931Invoice( + invoice_number=self.number, + invoice_date=self.issue_date, + currency_code=self.get_currency_display(), + grand_total_amount=total_amount, + tax_basis_total_amount=total_amount, + tax_total_amounts=[tax_amount] if self.vat_rate else [], + due_payable_amount=due_payable_amount, + prepaid_amount=prepaid_amount, + payment_reference=payment_reference, + payment_means=payment_means, + payment_terms=payment_terms, + line_total_amount=total_amount, + line_items=line_items, + type_code=type_code, + seller=TradeParty( + name="Weblate s.r.o.", + address=PostalAddress( + country_code="CZ", + post_code="471 54", + city="Cvikov", + line_one="Nábřežní 694", + ), + vat_id="CZ21668027", + ), + buyer=TradeParty( + name=self.customer.name, + address=PostalAddress( + country_code=self.customer.country.code, + post_code=self.customer.postcode, + city=self.customer.city, + line_one=self.customer.address, + line_two=self.customer.address_2 or None, + ), + vat_id=self.customer.vat or None, + ), + tax=[tax], + ) + + def generate_en_16931_xml(self) -> None: + invoice = self.get_en_16931_xml() + xml_string = generate_xml(invoice) + self.en_16931_xml_path.write_text(xml_string) + def generate_pdf(self) -> None: """Render invoice as PDF.""" # Create directory to store invoices @@ -697,6 +850,14 @@ def generate_pdf(self) -> None: render_pdf( html=self.render_html(), output=settings.INVOICES_PATH / self.filename, + pdf_variant="pdf/a-3b", + attachments=[ + Attachment( + string=generate_xml(self.get_en_16931_xml()), + base_url="factur-x.xml", + description="Factur-x invoice", + ) + ], ) def duplicate( # noqa: PLR0913 diff --git a/weblate_web/invoices/tests.py b/weblate_web/invoices/tests.py index 5e5a1e23c4..72fa529acd 100644 --- a/weblate_web/invoices/tests.py +++ b/weblate_web/invoices/tests.py @@ -1,11 +1,14 @@ from __future__ import annotations +import os from datetime import date, timedelta from decimal import Decimal from pathlib import Path from typing import cast +import requests from django.test.utils import override_settings +from drafthorse.utils import validate_xml from lxml import etree from weblate_web.models import Package, PackageCategory @@ -121,6 +124,25 @@ def validate_invoice(self, invoice: Invoice) -> None: xml_doc = etree.parse(invoice.xml_path) S3_SCHEMA.assertValid(xml_doc) + einvoice = invoice.en_16931_xml_path.read_bytes() + validate_xml(einvoice, "FACTUR-X_EN16931") + + if validator_url := os.environ.get("VALIDATOR_URL"): + # Validate standalone eInvoice + response = requests.post( + f"{validator_url}validate", + files={"invoice": einvoice}, + timeout=20, + ) + self.assertEqual(response.status_code, 200, response.text) + # Validate eInvoice included in the PDF + response = requests.post( + f"{validator_url}validate", + files={"invoice": invoice.path.read_bytes()}, + timeout=20, + ) + self.assertEqual(response.status_code, 200, response.text) + def test_dates(self) -> None: invoice = self.create_invoice(vat="CZ8003280318") self.assertEqual(invoice.tax_date, invoice.issue_date) diff --git a/weblate_web/pdf.py b/weblate_web/pdf.py index baabe9f96e..e9d459b922 100644 --- a/weblate_web/pdf.py +++ b/weblate_web/pdf.py @@ -19,12 +19,16 @@ from __future__ import annotations from pathlib import Path +from typing import TYPE_CHECKING from django.conf import settings from django.contrib.staticfiles import finders from weasyprint import CSS, HTML from weasyprint.text.fonts import FontConfiguration +if TYPE_CHECKING: + from weasyprint import Attachment + SIGNATURE_URL = "signature:" INVOICES_URL = "invoices:" LEGAL_URL = "legal:" @@ -63,7 +67,13 @@ def url_fetcher(url: str) -> dict[str, str | bytes]: return result -def render_pdf(*, html: str, output: Path) -> None: +def render_pdf( + *, + html: str, + output: Path, + attachments: list[Attachment] | None = None, + pdf_variant: str | None = None, +) -> None: font_config = FontConfiguration() renderer = HTML( @@ -78,8 +88,11 @@ def render_pdf(*, html: str, output: Path) -> None: font_config=font_config, url_fetcher=url_fetcher, ) + if attachments: + renderer.metadata.attachments = attachments renderer.write_pdf( output, stylesheets=[font_style], font_config=font_config, + pdf_variant=pdf_variant, )