Skip to content

Commit 52bcded

Browse files
committed
fix: support other recipient types
1 parent 9f55581 commit 52bcded

4 files changed

Lines changed: 239 additions & 18 deletions

File tree

documentation/docs/reference/invoice-models.md

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,16 +62,63 @@ issuer = Issuer(
6262

6363
## Subject (Recipient)
6464

65-
Information about the invoice recipient (buyer).
65+
Information about the invoice recipient (buyer). The `identification_data` field accepts four identification types matching the FA(3) schema's `DaneIdentyfikacyjne` choices for `Podmiot2`.
66+
67+
### Identification types
68+
69+
**Polish company (NIP)**
6670

6771
```python
68-
from ksef.models.invoice import Subject, SubjectIdentificationData, Address
72+
from ksef.models.invoice import Subject, NipIdentification
6973

7074
recipient = Subject(
71-
identification_data=SubjectIdentificationData(
72-
nip="0987654321",
73-
),
74-
# Optional: buyer address
75+
identification_data=NipIdentification(nip="0987654321"),
76+
)
77+
```
78+
79+
> `SubjectIdentificationData` is a backwards-compatible alias for `NipIdentification`.
80+
81+
**EU company (VAT)**
82+
83+
```python
84+
from ksef.models.invoice import Subject, EuVatIdentification
85+
86+
recipient = Subject(
87+
identification_data=EuVatIdentification(eu_country_code="DE", eu_vat_number="123456789"),
88+
name="Deutsche Firma GmbH",
89+
)
90+
```
91+
92+
**Non-EU entity**
93+
94+
```python
95+
from ksef.models.invoice import Subject, ForeignIdentification
96+
97+
recipient = Subject(
98+
identification_data=ForeignIdentification(country_code="US", tax_id="EIN123456"),
99+
name="American Corp.",
100+
)
101+
```
102+
103+
**Individual / no tax ID (B2C)**
104+
105+
```python
106+
from ksef.models.invoice import Subject, NoIdentification
107+
108+
recipient = Subject(
109+
identification_data=NoIdentification(),
110+
name="Jan Kowalski",
111+
)
112+
```
113+
114+
### Full example with address
115+
116+
```python
117+
from ksef.models.invoice import Subject, NipIdentification, Address
118+
119+
recipient = Subject(
120+
identification_data=NipIdentification(nip="0987654321"),
121+
name="Firma Testowa Sp. z o.o.",
75122
address=Address(
76123
country_code="PL",
77124
city="Kraków",
@@ -87,11 +134,21 @@ recipient = Subject(
87134

88135
| Field | Type | Required | Description |
89136
|-------|------|----------|-------------|
90-
| `identification_data` | `SubjectIdentificationData` | Yes | Recipient NIP |
137+
| `identification_data` | `NipIdentification \| EuVatIdentification \| ForeignIdentification \| NoIdentification` | Yes | Buyer identification (see types below) |
138+
| `name` | `str` | No | Buyer name (maps to `Nazwa` in `Podmiot2`) |
91139
| `address` | `Address` | No | Buyer address (maps to `Adres` in `Podmiot2`) |
92140
| `jst` | `int` | No | Is the buyer a local government unit? `1` = yes, `2` = no (default: `2`) |
93141
| `gv` | `int` | No | Is the buyer a government entity? `1` = yes, `2` = no (default: `2`) |
94142

143+
### Identification type fields
144+
145+
| Type | Fields | XML output |
146+
|------|--------|------------|
147+
| `NipIdentification` | `nip: str` | `<NIP>` |
148+
| `EuVatIdentification` | `eu_country_code: str`, `eu_vat_number: str` | `<KodUE>` + `<NrVatUE>` |
149+
| `ForeignIdentification` | `country_code: Optional[str]`, `tax_id: str` | `<KodKraju>` (optional) + `<NrID>` |
150+
| `NoIdentification` | *(none)* | `<BrakID>1</BrakID>` |
151+
95152
---
96153

97154
## Address

src/ksef/models/invoice.py

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from datetime import date, datetime
33
from decimal import Decimal
44
from enum import Enum
5-
from typing import Optional
5+
from typing import Optional, Union
66

77
from pydantic import BaseModel
88

@@ -21,16 +21,34 @@ class IssuerIdentificationData(BaseModel):
2121
full_name: str
2222

2323

24-
class SubjectIdentificationData(BaseModel):
25-
"""
26-
Subject identification data.
27-
28-
Corresponds to the field TPodmiot1/TPodmiot2 from the invoice XML schema.
29-
"""
24+
class NipIdentification(BaseModel):
25+
"""Polish NIP identification for Podmiot2."""
3026

3127
nip: str
3228

3329

30+
class EuVatIdentification(BaseModel):
31+
"""EU VAT identification for Podmiot2."""
32+
33+
eu_country_code: str
34+
eu_vat_number: str
35+
36+
37+
class ForeignIdentification(BaseModel):
38+
"""Non-EU foreign tax identification for Podmiot2."""
39+
40+
country_code: Optional[str] = None
41+
tax_id: str
42+
43+
44+
class NoIdentification(BaseModel):
45+
"""No tax identification (individuals, B2C) for Podmiot2."""
46+
47+
48+
# Backwards-compatible alias
49+
SubjectIdentificationData = NipIdentification
50+
51+
3452
class Address(BaseModel):
3553
"""Subject address data.
3654
@@ -52,7 +70,10 @@ class Subject(BaseModel):
5270
Corresponds to the field TPodmiot2 from the invoice XML schema.
5371
"""
5472

55-
identification_data: SubjectIdentificationData
73+
identification_data: Union[
74+
NipIdentification, EuVatIdentification, ForeignIdentification, NoIdentification
75+
]
76+
name: Optional[str] = None
5677
address: Optional[Address] = None
5778
jst: int = 2
5879
gv: int = 2

src/ksef/xml_converters.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@
33
from typing import Optional, cast
44
from xml.etree import ElementTree
55

6-
from ksef.models.invoice import Invoice
6+
from ksef.models.invoice import (
7+
EuVatIdentification,
8+
ForeignIdentification,
9+
Invoice,
10+
NipIdentification,
11+
NoIdentification,
12+
)
713

814
# FA(3) schema namespace
915
FA3_NAMESPACE = "http://crd.gov.pl/wzor/2025/06/25/13775/"
@@ -64,8 +70,29 @@ def _build_issuer(root: ElementTree.Element, invoice: Invoice) -> None:
6470
def _build_receiver(root: ElementTree.Element, invoice: Invoice) -> None:
6571
receiver = ElementTree.SubElement(root, "Podmiot2")
6672
receiver_id_data = ElementTree.SubElement(receiver, "DaneIdentyfikacyjne")
67-
receiver_nip = ElementTree.SubElement(receiver_id_data, "NIP")
68-
receiver_nip.text = invoice.recipient.identification_data.nip
73+
74+
id_data = invoice.recipient.identification_data
75+
if isinstance(id_data, NipIdentification):
76+
nip_el = ElementTree.SubElement(receiver_id_data, "NIP")
77+
nip_el.text = id_data.nip
78+
elif isinstance(id_data, EuVatIdentification):
79+
kod_ue = ElementTree.SubElement(receiver_id_data, "KodUE")
80+
kod_ue.text = id_data.eu_country_code
81+
nr_vat = ElementTree.SubElement(receiver_id_data, "NrVatUE")
82+
nr_vat.text = id_data.eu_vat_number
83+
elif isinstance(id_data, ForeignIdentification):
84+
if id_data.country_code is not None:
85+
kod_kraju = ElementTree.SubElement(receiver_id_data, "KodKraju")
86+
kod_kraju.text = id_data.country_code
87+
nr_id = ElementTree.SubElement(receiver_id_data, "NrID")
88+
nr_id.text = id_data.tax_id
89+
elif isinstance(id_data, NoIdentification):
90+
brak_id = ElementTree.SubElement(receiver_id_data, "BrakID")
91+
brak_id.text = "1"
92+
93+
if invoice.recipient.name is not None:
94+
nazwa = ElementTree.SubElement(receiver, "Nazwa")
95+
nazwa.text = invoice.recipient.name
6996

7097
if invoice.recipient.address is not None:
7198
addr = invoice.recipient.address

tests/xml_converters/test_convert_invoice_to_xml.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,15 @@
77

88
from ksef.models.invoice import (
99
Address,
10+
EuVatIdentification,
11+
ForeignIdentification,
1012
Invoice,
1113
InvoiceData,
1214
InvoiceType,
1315
Issuer,
1416
IssuerIdentificationData,
17+
NipIdentification,
18+
NoIdentification,
1519
Subject,
1620
SubjectIdentificationData,
1721
)
@@ -156,3 +160,115 @@ def test_without_apartment_number() -> None:
156160
actual_content = convert_invoice_to_xml(invoice)
157161
assert_xml_equal(actual_content=actual_content, expected_content=expected_content)
158162
assert b"NrLokalu" not in actual_content
163+
164+
165+
def _make_invoice(recipient: Subject) -> Invoice:
166+
"""Build a minimal invoice with the given recipient."""
167+
return Invoice(
168+
issuer=Issuer(
169+
identification_data=IssuerIdentificationData(
170+
nip="1111111111", full_name="Example Company 1 Sp z o. o."
171+
),
172+
email="example@example.com",
173+
phone="+48 111111111",
174+
address=Address(
175+
country_code="PL",
176+
city="Warszawa",
177+
street="Kwiatowa",
178+
house_number="1",
179+
apartment_number="2",
180+
postal_code="00-001",
181+
),
182+
),
183+
recipient=recipient,
184+
invoice_data=InvoiceData(
185+
currency_code="PLN",
186+
issue_date=date(2024, 1, 22),
187+
issue_number="FA/1/2024",
188+
sell_date=date(2024, 1, 1),
189+
total_amount=Decimal("450.00"),
190+
invoice_annotations=InvoiceAnnotations(
191+
tax_settlement_on_payment=TaxSettlementOnPayment.REGULAR,
192+
self_invoice=SelfInvoicing.NO,
193+
reverse_charge=ReverseCharge.NO,
194+
split_payment=SplitPayment.NO,
195+
free_from_vat=FreeFromVat.NO,
196+
intra_community_supply_of_new_transport_methods=IntraCommunitySupplyOfNewTransportMethods.NO,
197+
simplified_procedure_by_second_tax_payer=SimplifiedProcedureBySecondTaxPayer.NO,
198+
margin_procedure=MarginProcedure.NO,
199+
),
200+
invoice_type=InvoiceType.REGULAR_VAT,
201+
invoice_rows=InvoiceRows(
202+
rows=[
203+
InvoiceRow(name="Example service 1", tax=23),
204+
InvoiceRow(name="Example service 2", tax=8),
205+
]
206+
),
207+
),
208+
creation_datetime=datetime(2024, 1, 22, 10, 30, 0, tzinfo=timezone.utc),
209+
)
210+
211+
212+
def test_recipient_eu_vat() -> None:
213+
"""Test EU company recipient produces KodUE + NrVatUE elements."""
214+
invoice = _make_invoice(
215+
Subject(
216+
identification_data=EuVatIdentification(eu_country_code="DE", eu_vat_number="123456789")
217+
)
218+
)
219+
xml = convert_invoice_to_xml(invoice)
220+
soup = BeautifulSoup(xml, "xml")
221+
id_data = soup.find("Podmiot2").find("DaneIdentyfikacyjne")
222+
assert id_data.find("KodUE").text == "DE"
223+
assert id_data.find("NrVatUE").text == "123456789"
224+
assert id_data.find("NIP") is None
225+
226+
227+
def test_recipient_foreign_id() -> None:
228+
"""Test non-EU recipient with country code produces KodKraju + NrID."""
229+
invoice = _make_invoice(
230+
Subject(identification_data=ForeignIdentification(country_code="US", tax_id="EIN123"))
231+
)
232+
xml = convert_invoice_to_xml(invoice)
233+
soup = BeautifulSoup(xml, "xml")
234+
id_data = soup.find("Podmiot2").find("DaneIdentyfikacyjne")
235+
assert id_data.find("KodKraju").text == "US"
236+
assert id_data.find("NrID").text == "EIN123"
237+
assert id_data.find("NIP") is None
238+
239+
240+
def test_recipient_foreign_id_without_country() -> None:
241+
"""Test non-EU recipient without country code produces only NrID."""
242+
invoice = _make_invoice(Subject(identification_data=ForeignIdentification(tax_id="XYZ999")))
243+
xml = convert_invoice_to_xml(invoice)
244+
soup = BeautifulSoup(xml, "xml")
245+
id_data = soup.find("Podmiot2").find("DaneIdentyfikacyjne")
246+
assert id_data.find("NrID").text == "XYZ999"
247+
assert id_data.find("KodKraju") is None
248+
249+
250+
def test_recipient_no_id() -> None:
251+
"""Test individual/B2C recipient produces BrakID and supports Nazwa."""
252+
invoice = _make_invoice(Subject(identification_data=NoIdentification(), name="Jan Kowalski"))
253+
xml = convert_invoice_to_xml(invoice)
254+
soup = BeautifulSoup(xml, "xml")
255+
podmiot2 = soup.find("Podmiot2")
256+
id_data = podmiot2.find("DaneIdentyfikacyjne")
257+
assert id_data.find("BrakID").text == "1"
258+
assert id_data.find("NIP") is None
259+
assert podmiot2.find("Nazwa").text == "Jan Kowalski"
260+
261+
262+
def test_recipient_with_name() -> None:
263+
"""Test NIP recipient with name produces both NIP and Nazwa."""
264+
invoice = _make_invoice(
265+
Subject(
266+
identification_data=NipIdentification(nip="2222222222"),
267+
name="Firma Testowa Sp. z o.o.",
268+
)
269+
)
270+
xml = convert_invoice_to_xml(invoice)
271+
soup = BeautifulSoup(xml, "xml")
272+
podmiot2 = soup.find("Podmiot2")
273+
assert podmiot2.find("DaneIdentyfikacyjne").find("NIP").text == "2222222222"
274+
assert podmiot2.find("Nazwa").text == "Firma Testowa Sp. z o.o."

0 commit comments

Comments
 (0)