From f439f8aee7b4fd24487ae8aad141c9dc789a557c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Szafra=C5=84ski?= Date: Wed, 25 Mar 2026 09:16:16 +0100 Subject: [PATCH] feat: add invoice row details, tax summary fields, and fix XML element ordering --- src/ksef/models/invoice.py | 47 ++++++++++++++++ src/ksef/models/invoice_rows.py | 8 ++- src/ksef/xml_converters.py | 96 ++++++++++++++++++++++++++------- 3 files changed, 131 insertions(+), 20 deletions(-) diff --git a/src/ksef/models/invoice.py b/src/ksef/models/invoice.py index e19695a..5c6a9ba 100644 --- a/src/ksef/models/invoice.py +++ b/src/ksef/models/invoice.py @@ -10,6 +10,52 @@ from ksef.models.invoice_rows import InvoiceRows +class TaxSummary(BaseModel): + """Tax summary totals per rate for the Fa section. + + Each field pair (net + vat) corresponds to a tax rate group. + All fields are optional — only include those that apply to the invoice. + """ + + # 23% or 22% (standard rate) + net_standard: Optional[Decimal] = None # P_13_1 + vat_standard: Optional[Decimal] = None # P_14_1 + vat_standard_pln: Optional[Decimal] = None # P_14_1W (foreign currency) + + # 8% or 7% (first reduced rate) + net_reduced_1: Optional[Decimal] = None # P_13_2 + vat_reduced_1: Optional[Decimal] = None # P_14_2 + vat_reduced_1_pln: Optional[Decimal] = None # P_14_2W + + # 5% (second reduced rate) + net_reduced_2: Optional[Decimal] = None # P_13_3 + vat_reduced_2: Optional[Decimal] = None # P_14_3 + vat_reduced_2_pln: Optional[Decimal] = None # P_14_3W + + # 4% or 3% (taxi flat-rate) + net_flat_rate: Optional[Decimal] = None # P_13_4 + vat_flat_rate: Optional[Decimal] = None # P_14_4 + + # OSS/IOSS procedure tax + net_oss: Optional[Decimal] = None + vat_oss: Optional[Decimal] = None + + # 0% rates (no VAT fields — VAT is zero) + net_zero_domestic: Optional[Decimal] = None + net_zero_wdt: Optional[Decimal] = None + net_zero_export: Optional[Decimal] = None + + # Exempt from tax + net_exempt: Optional[Decimal] = None + + # Not subject to taxation + net_not_subject: Optional[Decimal] = None # P_13_8 (np I) + net_not_subject_art100: Optional[Decimal] = None # P_13_9 (np II) + + # Reverse charge (oo) + net_reverse_charge: Optional[Decimal] = None # P_13_10 + + class IssuerIdentificationData(BaseModel): """ Subject identification data. @@ -132,6 +178,7 @@ class InvoiceData(BaseModel): issue_number: str sell_date: date total_amount: Decimal + tax_summary: Optional[TaxSummary] = None invoice_annotations: InvoiceAnnotations invoice_type: InvoiceType invoice_rows: InvoiceRows diff --git a/src/ksef/models/invoice_rows.py b/src/ksef/models/invoice_rows.py index f23a794..d47456f 100644 --- a/src/ksef/models/invoice_rows.py +++ b/src/ksef/models/invoice_rows.py @@ -1,5 +1,6 @@ """Models for individual invoice rows/positions.""" +from datetime import date from decimal import Decimal from typing import Literal, Optional, Sequence @@ -45,9 +46,14 @@ class InvoiceRow(BaseModel): """Single individual invoice position.""" - name: str # P_7, nazwa (rodzaj) towaru lub usługi + name: str # P_7, product/service name + unit_of_measure: Optional[str] = None # P_8A, unit of measure (e.g. "szt", "C62") + quantity: Optional[Decimal] = None # P_8B, quantity + unit_net_price: Optional[Decimal] = None # P_9A, unit net price + net_value: Optional[Decimal] = None # P_11, net sales value tax: Optional[TaxRate] = None # P_12, standard tax rate tax_oss: Optional[Decimal] = None # P_12_XII, OSS/IOSS procedure tax rate (arbitrary %) + delivery_date: Optional[date] = None # P_6A, delivery/service completion date class InvoiceRows(BaseModel): diff --git a/src/ksef/xml_converters.py b/src/ksef/xml_converters.py index 3d666f8..09149a4 100644 --- a/src/ksef/xml_converters.py +++ b/src/ksef/xml_converters.py @@ -171,19 +171,56 @@ def _build_invoice_data_annotations(invoice_data: ElementTree.Element, invoice: p_pmn.text = "1" +def _build_tax_summary(parent: ElementTree.Element, invoice: Invoice) -> None: + """Emit P_13_*/P_14_* tax summary fields. Must be called before P_15.""" + ts = invoice.invoice_data.tax_summary + if ts is None: + return + + paired_fields = [ + (ts.net_standard, "P_13_1", ts.vat_standard, "P_14_1", ts.vat_standard_pln, "P_14_1W"), + (ts.net_reduced_1, "P_13_2", ts.vat_reduced_1, "P_14_2", ts.vat_reduced_1_pln, "P_14_2W"), + (ts.net_reduced_2, "P_13_3", ts.vat_reduced_2, "P_14_3", ts.vat_reduced_2_pln, "P_14_3W"), + (ts.net_flat_rate, "P_13_4", ts.vat_flat_rate, "P_14_4", None, None), + (ts.net_oss, "P_13_5", ts.vat_oss, "P_14_5", None, None), + ] + for net_val, net_tag, vat_val, vat_tag, vat_pln_val, vat_pln_tag in paired_fields: + if net_val is not None: + ElementTree.SubElement(parent, net_tag).text = str(net_val) + if vat_val is not None: + ElementTree.SubElement(parent, vat_tag).text = str(vat_val) + if vat_pln_val is not None and vat_pln_tag is not None: + ElementTree.SubElement(parent, vat_pln_tag).text = str(vat_pln_val) + + net_only_fields = [ + (ts.net_zero_domestic, "P_13_6_1"), + (ts.net_zero_wdt, "P_13_6_2"), + (ts.net_zero_export, "P_13_6_3"), + (ts.net_exempt, "P_13_7"), + (ts.net_not_subject, "P_13_8"), + (ts.net_not_subject_art100, "P_13_9"), + (ts.net_reverse_charge, "P_13_10"), + ] + for val, tag in net_only_fields: + if val is not None: + ElementTree.SubElement(parent, tag).text = str(val) + + def _build_invoice_data(root: ElementTree.Element, invoice: Invoice) -> None: invoice_data = ElementTree.SubElement(root, "Fa") - invoice_data_currency_code = ElementTree.SubElement(invoice_data, "KodWaluty") - invoice_data_issue_date = ElementTree.SubElement(invoice_data, "P_1") - invoice_data_invoice_number = ElementTree.SubElement(invoice_data, "P_2") - invoice_data_sell_date = ElementTree.SubElement(invoice_data, "P_6") - invoice_data_total_amount = ElementTree.SubElement(invoice_data, "P_15") - - invoice_data_currency_code.text = invoice.invoice_data.currency_code - invoice_data_issue_date.text = invoice.invoice_data.issue_date.strftime("%Y-%m-%d") - invoice_data_invoice_number.text = invoice.invoice_data.issue_number - invoice_data_sell_date.text = invoice.invoice_data.sell_date.strftime("%Y-%m-%d") - invoice_data_total_amount.text = str(invoice.invoice_data.total_amount) + + ElementTree.SubElement(invoice_data, "KodWaluty").text = invoice.invoice_data.currency_code + ElementTree.SubElement(invoice_data, "P_1").text = invoice.invoice_data.issue_date.strftime( + "%Y-%m-%d" + ) + ElementTree.SubElement(invoice_data, "P_2").text = invoice.invoice_data.issue_number + ElementTree.SubElement(invoice_data, "P_6").text = invoice.invoice_data.sell_date.strftime( + "%Y-%m-%d" + ) + + _build_tax_summary(invoice_data, invoice) + + ElementTree.SubElement(invoice_data, "P_15").text = str(invoice.invoice_data.total_amount) _build_invoice_data_annotations(invoice_data, invoice) @@ -193,18 +230,39 @@ def _build_invoice_data(root: ElementTree.Element, invoice: Invoice) -> None: for index, row in enumerate(invoice.invoice_data.invoice_rows.rows, start=1): invoice_data_row = ElementTree.SubElement(invoice_data, "FaWiersz") - invoice_data_row_number = ElementTree.SubElement(invoice_data_row, "NrWierszaFa") - invoice_data_row_name = ElementTree.SubElement(invoice_data_row, "P_7") - invoice_data_row_number.text = str(index) - invoice_data_row_name.text = row.name + nr = ElementTree.SubElement(invoice_data_row, "NrWierszaFa") + nr.text = str(index) + + if row.delivery_date is not None: + p_6a = ElementTree.SubElement(invoice_data_row, "P_6A") + p_6a.text = row.delivery_date.strftime("%Y-%m-%d") + + p_7 = ElementTree.SubElement(invoice_data_row, "P_7") + p_7.text = row.name + + if row.unit_of_measure is not None: + p_8a = ElementTree.SubElement(invoice_data_row, "P_8A") + p_8a.text = row.unit_of_measure + + if row.quantity is not None: + p_8b = ElementTree.SubElement(invoice_data_row, "P_8B") + p_8b.text = str(row.quantity) + + if row.unit_net_price is not None: + p_9a = ElementTree.SubElement(invoice_data_row, "P_9A") + p_9a.text = str(row.unit_net_price) + + if row.net_value is not None: + p_11 = ElementTree.SubElement(invoice_data_row, "P_11") + p_11.text = str(row.net_value) if row.tax_oss is not None: - invoice_data_row_tax_oss = ElementTree.SubElement(invoice_data_row, "P_12_XII") - invoice_data_row_tax_oss.text = str(row.tax_oss) + p_12_xii = ElementTree.SubElement(invoice_data_row, "P_12_XII") + p_12_xii.text = str(row.tax_oss) elif row.tax is not None: - invoice_data_row_tax_rate = ElementTree.SubElement(invoice_data_row, "P_12") - invoice_data_row_tax_rate.text = str(row.tax) + p_12 = ElementTree.SubElement(invoice_data_row, "P_12") + p_12.text = str(row.tax) def convert_invoice_to_xml(invoice: Invoice, invoicing_software_name: str = "python-ksef") -> bytes: