From a9ca54be6952f97995b72586cf3e30eb7e6dab1b Mon Sep 17 00:00:00 2001 From: Julien Palard Date: Fri, 21 Nov 2025 14:11:16 +0100 Subject: [PATCH 1/5] Make README example pass validation with EN16931. --- README.rst | 48 ++++++++++++++++++++++-------------------------- 1 file changed, 22 insertions(+), 26 deletions(-) diff --git a/README.rst b/README.rst index b2ba766..44f6f1e 100644 --- a/README.rst +++ b/README.rst @@ -49,34 +49,30 @@ Parsing:: Generating:: - from datetime import date, datetime, timezone + from datetime import date, datetime, timedelta, timezone from decimal import Decimal from drafthorse.models.accounting import ApplicableTradeTax from drafthorse.models.document import Document from drafthorse.models.note import IncludedNote from drafthorse.models.party import TaxRegistration + from drafthorse.models.payment import PaymentTerms from drafthorse.models.tradelines import LineItem from drafthorse.pdf import attach_xml # Build data structure doc = Document() - doc.context.guideline_parameter.id = "urn:cen.eu:en16931:2017#conformant#urn:factur-x.eu:1p0:extended" + doc.context.guideline_parameter.id = "urn:cen.eu:en16931:2017" doc.header.id = "RE1337" doc.header.type_code = "380" - doc.header.name = "RECHNUNG" doc.header.issue_date_time = date.today() - doc.header.languages.add("de") - note = IncludedNote() - note.content.add("Test Node 1") - doc.header.notes.add(note) + doc.header.notes.add(IncludedNote(content="Test Note 1")) doc.trade.agreement.seller.name = "Lieferant GmbH" - doc.trade.settlement.payee.name = "Lieferant GmbH" doc.trade.agreement.buyer.name = "Kunde GmbH" - doc.trade.settlement.invoicee.name = "Kunde GmbH" + doc.trade.agreement.buyer.address.country_id = "DE" doc.trade.settlement.currency_code = "EUR" doc.trade.settlement.payment_means.type_code = "ZZZ" @@ -89,41 +85,41 @@ Generating:: ) ) - doc.trade.agreement.seller_order.issue_date_time = datetime.now(timezone.utc) - doc.trade.agreement.buyer_order.issue_date_time = datetime.now(timezone.utc) doc.trade.settlement.advance_payment.received_date = datetime.now(timezone.utc) - doc.trade.agreement.customer_order.issue_date_time = datetime.now(timezone.utc) li = LineItem() li.document.line_id = "1" li.product.name = "Rainbow" - li.agreement.gross.amount = Decimal("999.00") - li.agreement.gross.basis_quantity = (Decimal("1.0000"), "H87") # H87 == pieces - li.agreement.net.amount = Decimal("999.00") - li.agreement.net.basis_quantity = (Decimal("999.00"), "EUR") - li.delivery.billed_quantity = (Decimal("1.0000"), "H87") # H87 == pieces + li.agreement.gross.amount = Decimal("1198.8") + li.agreement.gross.basis_quantity = (Decimal("1.0000"), "C62") # C62 == unit + li.agreement.net.amount = Decimal("999") + li.agreement.net.basis_quantity = (Decimal("1.0000"), "C62") # C62 == unit + li.delivery.billed_quantity = (Decimal("1.0000"), "C62") # C62 == unit li.settlement.trade_tax.type_code = "VAT" - li.settlement.trade_tax.category_code = "E" - li.settlement.trade_tax.rate_applicable_percent = Decimal("0.00") + li.settlement.trade_tax.category_code = "S" + li.settlement.trade_tax.rate_applicable_percent = Decimal("20.00") li.settlement.monetary_summation.total_amount = Decimal("999.00") doc.trade.items.add(li) trade_tax = ApplicableTradeTax() - trade_tax.calculated_amount = Decimal("0.00") + trade_tax.calculated_amount = Decimal("199.80") trade_tax.basis_amount = Decimal("999.00") trade_tax.type_code = "VAT" - trade_tax.category_code = "AE" - trade_tax.exemption_reason_code = 'VATEX-EU-AE' - trade_tax.rate_applicable_percent = Decimal("0.00") + trade_tax.category_code = "S" + trade_tax.rate_applicable_percent = Decimal("20.00") doc.trade.settlement.trade_tax.add(trade_tax) doc.trade.settlement.monetary_summation.line_total = Decimal("999.00") doc.trade.settlement.monetary_summation.charge_total = Decimal("0.00") doc.trade.settlement.monetary_summation.allowance_total = Decimal("0.00") doc.trade.settlement.monetary_summation.tax_basis_total = Decimal("999.00") - doc.trade.settlement.monetary_summation.tax_total = Decimal("0.00") - doc.trade.settlement.monetary_summation.grand_total = Decimal("999.00") - doc.trade.settlement.monetary_summation.due_amount = Decimal("999.00") + doc.trade.settlement.monetary_summation.tax_total = (Decimal("199.80"), "EUR") + doc.trade.settlement.monetary_summation.grand_total = Decimal("1198.8") + doc.trade.settlement.monetary_summation.due_amount = Decimal("1198.8") + + terms = PaymentTerms() + terms.due = datetime.now(timezone.utc) + timedelta(days=30) + doc.trade.settlement.terms.add(terms) # Generate XML file xml = doc.serialize(schema="FACTUR-X_EXTENDED") From 87e689c95d208a6242616d76e5424d43b88728a6 Mon Sep 17 00:00:00 2001 From: Julien Palard Date: Fri, 21 Nov 2025 17:40:55 +0100 Subject: [PATCH 2/5] Avoid copy/pasting the README example, it's too easily out of sync. --- tests/conftest.py | 82 ++++++++--------------------------------------- 1 file changed, 13 insertions(+), 69 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 5238a6f..7c1ad10 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,80 +1,24 @@ import os import pytest -from datetime import date, datetime, timezone -from decimal import Decimal +import re +from pathlib import Path +from textwrap import dedent -from drafthorse.models.accounting import ApplicableTradeTax -from drafthorse.models.document import Document -from drafthorse.models.note import IncludedNote -from drafthorse.models.party import TaxRegistration -from drafthorse.models.tradelines import LineItem +README_PATH = Path(__file__).parent.parent / "README.rst" @pytest.fixture def invoice_document(request): - doc = Document() - doc.context.guideline_parameter.id = ( - "urn:cen.eu:en16931:2017#conformant#urn:factur-x.eu:1p0:extended" + readme = README_PATH.read_text(encoding="UTF-8") + readme_example = re.search( + "Generating::$" "(?P.*)" "# Attach XML to an existing PDF.$", + readme, + flags=re.MULTILINE | re.DOTALL, ) - doc.header.id = "RE1337" - doc.header.type_code = request.param - doc.header.name = "RECHNUNG" - doc.header.issue_date_time = date.today() - doc.header.languages.add("de") - - doc.header.notes.add(IncludedNote(content="Test Node 1")) - - doc.trade.agreement.seller.name = "Lieferant GmbH" - doc.trade.settlement.payee.name = "Lieferant GmbH" - - doc.trade.agreement.buyer.name = "Kunde GmbH" - doc.trade.settlement.invoicee.name = "Kunde GmbH" - - doc.trade.settlement.currency_code = "EUR" - doc.trade.settlement.payment_means.type_code = "ZZZ" - - doc.trade.agreement.seller.address.country_id = "DE" - doc.trade.agreement.seller.address.country_subdivision = "Bayern" - doc.trade.agreement.seller.tax_registrations.add( - TaxRegistration(id=("VA", "DE000000000")) - ) - - doc.trade.agreement.seller_order.issue_date_time = datetime.now(timezone.utc) - doc.trade.agreement.buyer_order.issue_date_time = datetime.now(timezone.utc) - doc.trade.settlement.advance_payment.received_date = datetime.now(timezone.utc) - doc.trade.agreement.customer_order.issue_date_time = datetime.now(timezone.utc) - - li = LineItem() - li.document.line_id = "1" - li.product.name = "Rainbow" - li.agreement.gross.amount = Decimal("999.00") - li.agreement.gross.basis_quantity = (Decimal("1.0000"), "C62") # C62 == pieces - li.agreement.net.amount = Decimal("999.00") - li.agreement.net.basis_quantity = (Decimal("999.00"), "C62") - li.delivery.billed_quantity = (Decimal("1.0000"), "C62") # C62 == pieces - li.settlement.trade_tax.type_code = "VAT" - li.settlement.trade_tax.category_code = "E" - li.settlement.trade_tax.rate_applicable_percent = Decimal("0.00") - li.settlement.monetary_summation.total_amount = Decimal("999.00") - doc.trade.items.add(li) - - trade_tax = ApplicableTradeTax() - trade_tax.calculated_amount = Decimal("0.00") - trade_tax.basis_amount = Decimal("999.00") - trade_tax.type_code = "VAT" - trade_tax.category_code = "E" - trade_tax.rate_applicable_percent = Decimal("0.00") - doc.trade.settlement.trade_tax.add(trade_tax) - - doc.trade.settlement.monetary_summation.line_total = Decimal("999.00") - doc.trade.settlement.monetary_summation.charge_total = Decimal("0.00") - doc.trade.settlement.monetary_summation.allowance_total = Decimal("0.00") - doc.trade.settlement.monetary_summation.tax_basis_total = Decimal("999.00") - doc.trade.settlement.monetary_summation.tax_total = (Decimal("0.00"), "EUR") - doc.trade.settlement.monetary_summation.grand_total = Decimal("999.00") - doc.trade.settlement.monetary_summation.due_amount = Decimal("999.00") - - return doc + assert readme_example + local_ns = {} + exec(dedent(readme_example["example"]), locals=local_ns) + return local_ns['doc'] @pytest.fixture From fcbb9af23f2af30dd3e418b4a6e615d24015606e Mon Sep 17 00:00:00 2001 From: Julien Palard Date: Fri, 21 Nov 2025 17:42:49 +0100 Subject: [PATCH 3/5] Apply fixes from #102. --- README.rst | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/README.rst b/README.rst index 44f6f1e..63f6f94 100644 --- a/README.rst +++ b/README.rst @@ -57,9 +57,12 @@ Generating:: from drafthorse.models.note import IncludedNote from drafthorse.models.party import TaxRegistration from drafthorse.models.payment import PaymentTerms + from drafthorse.models.payment import PaymentMeans + from drafthorse.models.trade import AdvancePayment, IncludedTradeTax from drafthorse.models.tradelines import LineItem from drafthorse.pdf import attach_xml + # Build data structure doc = Document() doc.context.guideline_parameter.id = "urn:cen.eu:en16931:2017" @@ -69,14 +72,13 @@ Generating:: doc.header.notes.add(IncludedNote(content="Test Note 1")) - doc.trade.agreement.seller.name = "Lieferant GmbH" - doc.trade.agreement.buyer.name = "Kunde GmbH" doc.trade.agreement.buyer.address.country_id = "DE" doc.trade.settlement.currency_code = "EUR" - doc.trade.settlement.payment_means.type_code = "ZZZ" + doc.trade.settlement.payment_means.add(PaymentMeans(type_code="ZZZ")) + doc.trade.agreement.seller.name = "Lieferant GmbH" doc.trade.agreement.seller.address.country_id = "DE" doc.trade.agreement.seller.address.country_subdivision = "Bayern" doc.trade.agreement.seller.tax_registrations.add( @@ -85,7 +87,18 @@ Generating:: ) ) - doc.trade.settlement.advance_payment.received_date = datetime.now(timezone.utc) + advance = AdvancePayment( + received_date=datetime.now(timezone.utc), paid_amount=Decimal(42) + ) + advance.included_trade_tax.add( + IncludedTradeTax( + calculated_amount=Decimal(0), + type_code="VAT", + category_code="E", + rate_applicable_percent=Decimal(0), + ) + ) + doc.trade.settlement.advance_payment.add(advance) li = LineItem() li.document.line_id = "1" From b9afc2d7997e62adcd7241e132de7cc58b899c04 Mon Sep 17 00:00:00 2001 From: Julien Palard Date: Fri, 21 Nov 2025 18:30:59 +0100 Subject: [PATCH 4/5] Alternative implementation using cog. --- README.rst | 17 +++++++-- example.py | 85 ++++++++++++++++++++++++++++++++++++++++++++ requirements_dev.txt | 1 + tests/conftest.py | 17 ++------- 4 files changed, 102 insertions(+), 18 deletions(-) create mode 100644 example.py diff --git a/README.rst b/README.rst index 63f6f94..ae18e03 100644 --- a/README.rst +++ b/README.rst @@ -47,6 +47,17 @@ Parsing:: ``Document.parse()`` taskes a boolean parameter ``strict`` which defaults to ``True``. This means that the parser will raise an error if it encounters any unknown element. If you set it to ``False``, the parser will not raise an error and parse whatever it can. +.. [[[cog + # Re-run this with `cog -r README.rst` + + from pathlib import Path + from textwrap import indent + + import cog + + cog.outl("Generating::\n") + cog.outl(indent(Path("example.py").read_text(encoding="UTF-8"), " "), dedent=False) +.. ]]] Generating:: from datetime import date, datetime, timedelta, timezone @@ -56,13 +67,11 @@ Generating:: from drafthorse.models.document import Document from drafthorse.models.note import IncludedNote from drafthorse.models.party import TaxRegistration - from drafthorse.models.payment import PaymentTerms - from drafthorse.models.payment import PaymentMeans + from drafthorse.models.payment import PaymentMeans, PaymentTerms from drafthorse.models.trade import AdvancePayment, IncludedTradeTax from drafthorse.models.tradelines import LineItem from drafthorse.pdf import attach_xml - # Build data structure doc = Document() doc.context.guideline_parameter.id = "urn:cen.eu:en16931:2017" @@ -137,6 +146,8 @@ Generating:: # Generate XML file xml = doc.serialize(schema="FACTUR-X_EXTENDED") +.. [[[end]]] + # Attach XML to an existing PDF. # Note that the existing PDF should be compliant to PDF/A-3! # You can validate this here: https://www.pdf-online.com/osa/validate.aspx diff --git a/example.py b/example.py new file mode 100644 index 0000000..25c069b --- /dev/null +++ b/example.py @@ -0,0 +1,85 @@ +from datetime import date, datetime, timedelta, timezone +from decimal import Decimal + +from drafthorse.models.accounting import ApplicableTradeTax +from drafthorse.models.document import Document +from drafthorse.models.note import IncludedNote +from drafthorse.models.party import TaxRegistration +from drafthorse.models.payment import PaymentMeans, PaymentTerms +from drafthorse.models.trade import AdvancePayment, IncludedTradeTax +from drafthorse.models.tradelines import LineItem +from drafthorse.pdf import attach_xml + +# Build data structure +doc = Document() +doc.context.guideline_parameter.id = "urn:cen.eu:en16931:2017" +doc.header.id = "RE1337" +doc.header.type_code = "380" +doc.header.issue_date_time = date.today() + +doc.header.notes.add(IncludedNote(content="Test Note 1")) + +doc.trade.agreement.buyer.name = "Kunde GmbH" +doc.trade.agreement.buyer.address.country_id = "DE" + +doc.trade.settlement.currency_code = "EUR" +doc.trade.settlement.payment_means.add(PaymentMeans(type_code="ZZZ")) + +doc.trade.agreement.seller.name = "Lieferant GmbH" +doc.trade.agreement.seller.address.country_id = "DE" +doc.trade.agreement.seller.address.country_subdivision = "Bayern" +doc.trade.agreement.seller.tax_registrations.add( + TaxRegistration( + id=("VA", "DE000000000") + ) +) + +advance = AdvancePayment( + received_date=datetime.now(timezone.utc), paid_amount=Decimal(42) +) +advance.included_trade_tax.add( + IncludedTradeTax( + calculated_amount=Decimal(0), + type_code="VAT", + category_code="E", + rate_applicable_percent=Decimal(0), + ) +) +doc.trade.settlement.advance_payment.add(advance) + +li = LineItem() +li.document.line_id = "1" +li.product.name = "Rainbow" +li.agreement.gross.amount = Decimal("1198.8") +li.agreement.gross.basis_quantity = (Decimal("1.0000"), "C62") # C62 == unit +li.agreement.net.amount = Decimal("999") +li.agreement.net.basis_quantity = (Decimal("1.0000"), "C62") # C62 == unit +li.delivery.billed_quantity = (Decimal("1.0000"), "C62") # C62 == unit +li.settlement.trade_tax.type_code = "VAT" +li.settlement.trade_tax.category_code = "S" +li.settlement.trade_tax.rate_applicable_percent = Decimal("20.00") +li.settlement.monetary_summation.total_amount = Decimal("999.00") +doc.trade.items.add(li) + +trade_tax = ApplicableTradeTax() +trade_tax.calculated_amount = Decimal("199.80") +trade_tax.basis_amount = Decimal("999.00") +trade_tax.type_code = "VAT" +trade_tax.category_code = "S" +trade_tax.rate_applicable_percent = Decimal("20.00") +doc.trade.settlement.trade_tax.add(trade_tax) + +doc.trade.settlement.monetary_summation.line_total = Decimal("999.00") +doc.trade.settlement.monetary_summation.charge_total = Decimal("0.00") +doc.trade.settlement.monetary_summation.allowance_total = Decimal("0.00") +doc.trade.settlement.monetary_summation.tax_basis_total = Decimal("999.00") +doc.trade.settlement.monetary_summation.tax_total = (Decimal("199.80"), "EUR") +doc.trade.settlement.monetary_summation.grand_total = Decimal("1198.8") +doc.trade.settlement.monetary_summation.due_amount = Decimal("1198.8") + +terms = PaymentTerms() +terms.due = datetime.now(timezone.utc) + timedelta(days=30) +doc.trade.settlement.terms.add(terms) + +# Generate XML file +xml = doc.serialize(schema="FACTUR-X_EXTENDED") diff --git a/requirements_dev.txt b/requirements_dev.txt index 17ce34c..2c5a241 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,3 +1,4 @@ +cogapp lxml pypdf pytest diff --git a/tests/conftest.py b/tests/conftest.py index 7c1ad10..75fe735 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,24 +1,11 @@ import os import pytest -import re -from pathlib import Path -from textwrap import dedent - -README_PATH = Path(__file__).parent.parent / "README.rst" +from example import doc @pytest.fixture def invoice_document(request): - readme = README_PATH.read_text(encoding="UTF-8") - readme_example = re.search( - "Generating::$" "(?P.*)" "# Attach XML to an existing PDF.$", - readme, - flags=re.MULTILINE | re.DOTALL, - ) - assert readme_example - local_ns = {} - exec(dedent(readme_example["example"]), locals=local_ns) - return local_ns['doc'] + return doc @pytest.fixture From 7bfc1c6681b022a50d92f81ea0897726b93b875f Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Thu, 27 Nov 2025 21:04:29 +0100 Subject: [PATCH 5/5] Run black --- README.rst | 4 +--- example.py | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/README.rst b/README.rst index 17c62dd..b9dd812 100644 --- a/README.rst +++ b/README.rst @@ -93,9 +93,7 @@ Generating:: doc.trade.agreement.seller.address.country_id = "DE" doc.trade.agreement.seller.address.country_subdivision = "Bayern" doc.trade.agreement.seller.tax_registrations.add( - TaxRegistration( - id=("VA", "DE000000000") - ) + TaxRegistration(id=("VA", "DE000000000")) ) advance = AdvancePayment( diff --git a/example.py b/example.py index 25c069b..95f90e5 100644 --- a/example.py +++ b/example.py @@ -29,9 +29,7 @@ doc.trade.agreement.seller.address.country_id = "DE" doc.trade.agreement.seller.address.country_subdivision = "Bayern" doc.trade.agreement.seller.tax_registrations.add( - TaxRegistration( - id=("VA", "DE000000000") - ) + TaxRegistration(id=("VA", "DE000000000")) ) advance = AdvancePayment(