Skip to content

Commit 342fb34

Browse files
authored
feat: add invoice row details, tax summary fields, and fix XML element ordering (#36)
- Fix: Nazwa element placement — Moved buyer name (Nazwa) inside DaneIdentyfikacyjne where the FA(3) schema requires it, instead of directly under Podmiot2 - Fix: XML element ordering — Moved Adres before Nazwa in Podmiot2, and P_13/P_14 tax summary before P_15 in Fa - Add: Invoice row fields — InvoiceRow now supports unit_of_measure (P_8A), quantity (P_8B), unit_net_price (P_9A), net_value (P_11), and delivery_date (P_6A) - Add: Tax rate type safety — Replaced int tax field with TaxRate literal type covering all 14 valid TStawkaPodatku enum values. Added constants (TAX_23, TAX_0_WDT, TAX_NP_I, etc.) - Add: OSS/IOSS support — InvoiceRow.tax_oss field (P_12_XII) for EU consumer VAT rates not in the standard enum (e.g. 21%, 19%) - Add: Tax summary — TaxSummary model with all P_13_*/P_14_* fields for net/VAT totals per rate group, added as optional field on InvoiceData
1 parent 809f059 commit 342fb34

File tree

3 files changed

+131
-20
lines changed

3 files changed

+131
-20
lines changed

src/ksef/models/invoice.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,52 @@
1010
from ksef.models.invoice_rows import InvoiceRows
1111

1212

13+
class TaxSummary(BaseModel):
14+
"""Tax summary totals per rate for the Fa section.
15+
16+
Each field pair (net + vat) corresponds to a tax rate group.
17+
All fields are optional — only include those that apply to the invoice.
18+
"""
19+
20+
# 23% or 22% (standard rate)
21+
net_standard: Optional[Decimal] = None # P_13_1
22+
vat_standard: Optional[Decimal] = None # P_14_1
23+
vat_standard_pln: Optional[Decimal] = None # P_14_1W (foreign currency)
24+
25+
# 8% or 7% (first reduced rate)
26+
net_reduced_1: Optional[Decimal] = None # P_13_2
27+
vat_reduced_1: Optional[Decimal] = None # P_14_2
28+
vat_reduced_1_pln: Optional[Decimal] = None # P_14_2W
29+
30+
# 5% (second reduced rate)
31+
net_reduced_2: Optional[Decimal] = None # P_13_3
32+
vat_reduced_2: Optional[Decimal] = None # P_14_3
33+
vat_reduced_2_pln: Optional[Decimal] = None # P_14_3W
34+
35+
# 4% or 3% (taxi flat-rate)
36+
net_flat_rate: Optional[Decimal] = None # P_13_4
37+
vat_flat_rate: Optional[Decimal] = None # P_14_4
38+
39+
# OSS/IOSS procedure tax
40+
net_oss: Optional[Decimal] = None
41+
vat_oss: Optional[Decimal] = None
42+
43+
# 0% rates (no VAT fields — VAT is zero)
44+
net_zero_domestic: Optional[Decimal] = None
45+
net_zero_wdt: Optional[Decimal] = None
46+
net_zero_export: Optional[Decimal] = None
47+
48+
# Exempt from tax
49+
net_exempt: Optional[Decimal] = None
50+
51+
# Not subject to taxation
52+
net_not_subject: Optional[Decimal] = None # P_13_8 (np I)
53+
net_not_subject_art100: Optional[Decimal] = None # P_13_9 (np II)
54+
55+
# Reverse charge (oo)
56+
net_reverse_charge: Optional[Decimal] = None # P_13_10
57+
58+
1359
class IssuerIdentificationData(BaseModel):
1460
"""
1561
Subject identification data.
@@ -132,6 +178,7 @@ class InvoiceData(BaseModel):
132178
issue_number: str
133179
sell_date: date
134180
total_amount: Decimal
181+
tax_summary: Optional[TaxSummary] = None
135182
invoice_annotations: InvoiceAnnotations
136183
invoice_type: InvoiceType
137184
invoice_rows: InvoiceRows

src/ksef/models/invoice_rows.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Models for individual invoice rows/positions."""
22

3+
from datetime import date
34
from decimal import Decimal
45
from typing import Literal, Optional, Sequence
56

@@ -45,9 +46,14 @@
4546
class InvoiceRow(BaseModel):
4647
"""Single individual invoice position."""
4748

48-
name: str # P_7, nazwa (rodzaj) towaru lub usługi
49+
name: str # P_7, product/service name
50+
unit_of_measure: Optional[str] = None # P_8A, unit of measure (e.g. "szt", "C62")
51+
quantity: Optional[Decimal] = None # P_8B, quantity
52+
unit_net_price: Optional[Decimal] = None # P_9A, unit net price
53+
net_value: Optional[Decimal] = None # P_11, net sales value
4954
tax: Optional[TaxRate] = None # P_12, standard tax rate
5055
tax_oss: Optional[Decimal] = None # P_12_XII, OSS/IOSS procedure tax rate (arbitrary %)
56+
delivery_date: Optional[date] = None # P_6A, delivery/service completion date
5157

5258

5359
class InvoiceRows(BaseModel):

src/ksef/xml_converters.py

Lines changed: 77 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -171,19 +171,56 @@ def _build_invoice_data_annotations(invoice_data: ElementTree.Element, invoice:
171171
p_pmn.text = "1"
172172

173173

174+
def _build_tax_summary(parent: ElementTree.Element, invoice: Invoice) -> None:
175+
"""Emit P_13_*/P_14_* tax summary fields. Must be called before P_15."""
176+
ts = invoice.invoice_data.tax_summary
177+
if ts is None:
178+
return
179+
180+
paired_fields = [
181+
(ts.net_standard, "P_13_1", ts.vat_standard, "P_14_1", ts.vat_standard_pln, "P_14_1W"),
182+
(ts.net_reduced_1, "P_13_2", ts.vat_reduced_1, "P_14_2", ts.vat_reduced_1_pln, "P_14_2W"),
183+
(ts.net_reduced_2, "P_13_3", ts.vat_reduced_2, "P_14_3", ts.vat_reduced_2_pln, "P_14_3W"),
184+
(ts.net_flat_rate, "P_13_4", ts.vat_flat_rate, "P_14_4", None, None),
185+
(ts.net_oss, "P_13_5", ts.vat_oss, "P_14_5", None, None),
186+
]
187+
for net_val, net_tag, vat_val, vat_tag, vat_pln_val, vat_pln_tag in paired_fields:
188+
if net_val is not None:
189+
ElementTree.SubElement(parent, net_tag).text = str(net_val)
190+
if vat_val is not None:
191+
ElementTree.SubElement(parent, vat_tag).text = str(vat_val)
192+
if vat_pln_val is not None and vat_pln_tag is not None:
193+
ElementTree.SubElement(parent, vat_pln_tag).text = str(vat_pln_val)
194+
195+
net_only_fields = [
196+
(ts.net_zero_domestic, "P_13_6_1"),
197+
(ts.net_zero_wdt, "P_13_6_2"),
198+
(ts.net_zero_export, "P_13_6_3"),
199+
(ts.net_exempt, "P_13_7"),
200+
(ts.net_not_subject, "P_13_8"),
201+
(ts.net_not_subject_art100, "P_13_9"),
202+
(ts.net_reverse_charge, "P_13_10"),
203+
]
204+
for val, tag in net_only_fields:
205+
if val is not None:
206+
ElementTree.SubElement(parent, tag).text = str(val)
207+
208+
174209
def _build_invoice_data(root: ElementTree.Element, invoice: Invoice) -> None:
175210
invoice_data = ElementTree.SubElement(root, "Fa")
176-
invoice_data_currency_code = ElementTree.SubElement(invoice_data, "KodWaluty")
177-
invoice_data_issue_date = ElementTree.SubElement(invoice_data, "P_1")
178-
invoice_data_invoice_number = ElementTree.SubElement(invoice_data, "P_2")
179-
invoice_data_sell_date = ElementTree.SubElement(invoice_data, "P_6")
180-
invoice_data_total_amount = ElementTree.SubElement(invoice_data, "P_15")
181-
182-
invoice_data_currency_code.text = invoice.invoice_data.currency_code
183-
invoice_data_issue_date.text = invoice.invoice_data.issue_date.strftime("%Y-%m-%d")
184-
invoice_data_invoice_number.text = invoice.invoice_data.issue_number
185-
invoice_data_sell_date.text = invoice.invoice_data.sell_date.strftime("%Y-%m-%d")
186-
invoice_data_total_amount.text = str(invoice.invoice_data.total_amount)
211+
212+
ElementTree.SubElement(invoice_data, "KodWaluty").text = invoice.invoice_data.currency_code
213+
ElementTree.SubElement(invoice_data, "P_1").text = invoice.invoice_data.issue_date.strftime(
214+
"%Y-%m-%d"
215+
)
216+
ElementTree.SubElement(invoice_data, "P_2").text = invoice.invoice_data.issue_number
217+
ElementTree.SubElement(invoice_data, "P_6").text = invoice.invoice_data.sell_date.strftime(
218+
"%Y-%m-%d"
219+
)
220+
221+
_build_tax_summary(invoice_data, invoice)
222+
223+
ElementTree.SubElement(invoice_data, "P_15").text = str(invoice.invoice_data.total_amount)
187224

188225
_build_invoice_data_annotations(invoice_data, invoice)
189226

@@ -193,18 +230,39 @@ def _build_invoice_data(root: ElementTree.Element, invoice: Invoice) -> None:
193230

194231
for index, row in enumerate(invoice.invoice_data.invoice_rows.rows, start=1):
195232
invoice_data_row = ElementTree.SubElement(invoice_data, "FaWiersz")
196-
invoice_data_row_number = ElementTree.SubElement(invoice_data_row, "NrWierszaFa")
197-
invoice_data_row_name = ElementTree.SubElement(invoice_data_row, "P_7")
198233

199-
invoice_data_row_number.text = str(index)
200-
invoice_data_row_name.text = row.name
234+
nr = ElementTree.SubElement(invoice_data_row, "NrWierszaFa")
235+
nr.text = str(index)
236+
237+
if row.delivery_date is not None:
238+
p_6a = ElementTree.SubElement(invoice_data_row, "P_6A")
239+
p_6a.text = row.delivery_date.strftime("%Y-%m-%d")
240+
241+
p_7 = ElementTree.SubElement(invoice_data_row, "P_7")
242+
p_7.text = row.name
243+
244+
if row.unit_of_measure is not None:
245+
p_8a = ElementTree.SubElement(invoice_data_row, "P_8A")
246+
p_8a.text = row.unit_of_measure
247+
248+
if row.quantity is not None:
249+
p_8b = ElementTree.SubElement(invoice_data_row, "P_8B")
250+
p_8b.text = str(row.quantity)
251+
252+
if row.unit_net_price is not None:
253+
p_9a = ElementTree.SubElement(invoice_data_row, "P_9A")
254+
p_9a.text = str(row.unit_net_price)
255+
256+
if row.net_value is not None:
257+
p_11 = ElementTree.SubElement(invoice_data_row, "P_11")
258+
p_11.text = str(row.net_value)
201259

202260
if row.tax_oss is not None:
203-
invoice_data_row_tax_oss = ElementTree.SubElement(invoice_data_row, "P_12_XII")
204-
invoice_data_row_tax_oss.text = str(row.tax_oss)
261+
p_12_xii = ElementTree.SubElement(invoice_data_row, "P_12_XII")
262+
p_12_xii.text = str(row.tax_oss)
205263
elif row.tax is not None:
206-
invoice_data_row_tax_rate = ElementTree.SubElement(invoice_data_row, "P_12")
207-
invoice_data_row_tax_rate.text = str(row.tax)
264+
p_12 = ElementTree.SubElement(invoice_data_row, "P_12")
265+
p_12.text = str(row.tax)
208266

209267

210268
def convert_invoice_to_xml(invoice: Invoice, invoicing_software_name: str = "python-ksef") -> bytes:

0 commit comments

Comments
 (0)