Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ SDK zostało zaprojektowane w oparciu o oficjalne biblioteki referencyjne KSeF d

## 🔄 Kompatybilność API KSeF

Aktualna kompatybilność: **KSeF API `v2.1.1`** ([api-changelog.md](https://github.com/CIRFMF/ksef-docs/blob/2.1.1/api-changelog.md)).
Aktualna kompatybilność: **KSeF API `v2.1.2`** ([api-changelog.md](https://github.com/CIRFMF/ksef-docs/blob/2.1.2/api-changelog.md)).

## ✅ Funkcjonalności

Expand Down
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Dokumentacja opisuje **publiczne API** biblioteki `ksef-client-python` (import:

Opis kontraktu API (OpenAPI) oraz dokumenty procesowe i ograniczenia systemu znajdują się w `ksef-docs/`.

Kompatybilność SDK: **KSeF API `v2.1.1`**.
Kompatybilność SDK: **KSeF API `v2.1.2`**.

## Wymagania

Expand Down
7 changes: 7 additions & 0 deletions docs/api/invoices.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ Endpoint służy do wyszukiwania metadanych faktur. `request_payload` zależy od

Typowe zastosowanie: synchronizacja historii metadanych i późniejsze pobieranie treści XML po `ksefNumber`.

Uwaga dla `dateRange`:
- jeśli `dateRange.from` / `dateRange.to` jest podane jako ISO date-time bez offsetu (`YYYY-MM-DDTHH:MM[:SS]`),
SDK normalizuje je do strefy `Europe/Warsaw` i wysyła z jawnie dopisanym offsetem (`+01:00`/`+02:00`).

## `export_invoices(request_payload, access_token)`

Endpoint: `POST /invoices/exports`
Expand All @@ -33,6 +37,9 @@ Wymagane minimum w `request_payload`:
- `encryption.initializationVector`
- `filters` (np. `subjectType` + `dateRange`)

Uwaga dla `filters.dateRange`:
- ISO date-time bez offsetu jest normalizowany do `Europe/Warsaw` przed wysyłką requestu.

## `get_export_status(reference_number, access_token)`

Endpoint: `GET /invoices/exports/{referenceNumber}`
Expand Down
68 changes: 64 additions & 4 deletions src/ksef_client/clients/invoices.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,66 @@
from __future__ import annotations

import re
from copy import deepcopy
from datetime import date, datetime, timedelta, timezone
from typing import Any

from ..models import BinaryContent, InvoiceContent
from .base import AsyncBaseApiClient, BaseApiClient

_OFFSET_SUFFIX_RE = re.compile(r"(?:Z|[+-]\d{2}:?\d{2})$")


def _last_sunday_of_month(year: int, month: int) -> int:
cursor = date(year, 12, 31) if month == 12 else date(year, month + 1, 1) - timedelta(days=1)
while cursor.weekday() != 6: # Sunday
cursor -= timedelta(days=1)
return cursor.day


def _warsaw_offset_for_local_datetime(local_dt: datetime) -> timedelta:
year = local_dt.year
dst_start = datetime(year, 3, _last_sunday_of_month(year, 3), 2, 0, 0)
dst_end = datetime(year, 10, _last_sunday_of_month(year, 10), 3, 0, 0)
if dst_start <= local_dt < dst_end:
return timedelta(hours=2)
return timedelta(hours=1)


def _normalize_datetime_without_offset(value: str) -> str:
if "T" not in value or _OFFSET_SUFFIX_RE.search(value):
return value
try:
parsed = datetime.fromisoformat(value)
except ValueError:
return value
if parsed.tzinfo is not None:
return value
offset = _warsaw_offset_for_local_datetime(parsed)
return parsed.replace(tzinfo=timezone(offset)).isoformat()


def _normalize_invoice_date_range_payload(request_payload: dict[str, Any]) -> dict[str, Any]:
normalized = deepcopy(request_payload)
date_range_candidates: list[dict[str, Any]] = []

top_level = normalized.get("dateRange")
if isinstance(top_level, dict):
date_range_candidates.append(top_level)

filters = normalized.get("filters")
if isinstance(filters, dict):
nested = filters.get("dateRange")
if isinstance(nested, dict):
date_range_candidates.append(nested)

for date_range in date_range_candidates:
for field_name in ("from", "to"):
value = date_range.get(field_name)
if isinstance(value, str):
date_range[field_name] = _normalize_datetime_without_offset(value)
return normalized


class InvoicesClient(BaseApiClient):
def get_invoice(self, ksef_number: str, *, access_token: str) -> InvoiceContent:
Expand Down Expand Up @@ -39,6 +95,7 @@ def query_invoice_metadata(
page_size: int | None = None,
sort_order: str | None = None,
) -> Any:
normalized_payload = _normalize_invoice_date_range_payload(request_payload)
params: dict[str, Any] = {}
if page_offset is not None:
params["pageOffset"] = page_offset
Expand All @@ -50,15 +107,16 @@ def query_invoice_metadata(
"POST",
"/invoices/query/metadata",
params=params or None,
json=request_payload,
json=normalized_payload,
access_token=access_token,
)

def export_invoices(self, request_payload: dict[str, Any], *, access_token: str) -> Any:
normalized_payload = _normalize_invoice_date_range_payload(request_payload)
return self._request_json(
"POST",
"/invoices/exports",
json=request_payload,
json=normalized_payload,
access_token=access_token,
expected_status={201, 202},
)
Expand Down Expand Up @@ -119,6 +177,7 @@ async def query_invoice_metadata(
page_size: int | None = None,
sort_order: str | None = None,
) -> Any:
normalized_payload = _normalize_invoice_date_range_payload(request_payload)
params: dict[str, Any] = {}
if page_offset is not None:
params["pageOffset"] = page_offset
Expand All @@ -130,15 +189,16 @@ async def query_invoice_metadata(
"POST",
"/invoices/query/metadata",
params=params or None,
json=request_payload,
json=normalized_payload,
access_token=access_token,
)

async def export_invoices(self, request_payload: dict[str, Any], *, access_token: str) -> Any:
normalized_payload = _normalize_invoice_date_range_payload(request_payload)
return await self._request_json(
"POST",
"/invoices/exports",
json=request_payload,
json=normalized_payload,
access_token=access_token,
expected_status={201, 202},
)
Expand Down
5 changes: 3 additions & 2 deletions src/ksef_client/openapi_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,7 @@ class TokenPermissionType(Enum):
CREDENTIALSMANAGE = "CredentialsManage"
SUBUNITMANAGE = "SubunitManage"
ENFORCEMENTOPERATIONS = "EnforcementOperations"
INTROSPECTION = "Introspection"

Challenge: TypeAlias = str

Expand Down Expand Up @@ -1291,7 +1292,7 @@ class InvoiceStatusInfo(OpenApiModel):
code: int
description: str
details: Optional[list[str]] = None
extensions: Optional[dict[str, Optional[str]]] = None
extensions: Optional[dict[str, Any]] = None

@dataclass(frozen=True)
class OnlineSessionContextLimitsOverride(OpenApiModel):
Expand Down Expand Up @@ -1329,7 +1330,7 @@ class OpenOnlineSessionResponse(OpenApiModel):

@dataclass(frozen=True)
class PartUploadRequest(OpenApiModel):
headers: dict[str, Optional[str]]
headers: dict[str, Any]
method: str
ordinalNumber: int
url: str
Expand Down
78 changes: 72 additions & 6 deletions tests/test_clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,28 +110,65 @@ def test_sessions_client(self):

def test_invoices_client(self):
client = InvoicesClient(self.http)
query_payload = {
"subjectType": "Subject1",
"dateRange": {
"dateType": "Issue",
"from": "2025-01-02T10:15:00",
"to": "2025-01-02T11:15:00",
},
}
export_payload = {
"encryption": {"encryptedSymmetricKey": "abc", "initializationVector": "def"},
"filters": {
"subjectType": "Subject1",
"dateRange": {
"dateType": "Issue",
"from": "2025-07-02T10:15:00",
"to": "2025-07-02T11:15:00",
},
},
}
with (
patch.object(client, "_request_raw", Mock(return_value=self.response)),
patch.object(client, "_request_json", Mock(return_value={"ok": True})),
patch.object(
client, "_request_json", Mock(return_value={"ok": True})
) as request_json_mock,
patch.object(client, "_request_bytes", Mock(return_value=b"bytes")),
):
invoice = client.get_invoice("ksef", access_token="token")
self.assertEqual(invoice.sha256_base64, "hash")
invoice_bytes = client.get_invoice_bytes("ksef", access_token="token")
self.assertEqual(invoice_bytes.sha256_base64, "hash")
client.query_invoice_metadata(
{"a": 1},
query_payload,
access_token="token",
page_offset=0,
page_size=10,
sort_order="asc",
)
client.export_invoices({"a": 1}, access_token="token")
client.export_invoices(export_payload, access_token="token")
client.get_export_status("ref", access_token="token")
client.download_export_part("https://example.com")
client.download_package_part("https://example.com")
with_hash = client.download_export_part_with_hash("https://example.com")
self.assertEqual(with_hash.sha256_base64, "hash")
self.assertEqual(
request_json_mock.call_args_list[0].kwargs["json"]["dateRange"]["from"],
"2025-01-02T10:15:00+01:00",
)
self.assertEqual(
request_json_mock.call_args_list[0].kwargs["json"]["dateRange"]["to"],
"2025-01-02T11:15:00+01:00",
)
self.assertEqual(
request_json_mock.call_args_list[1].kwargs["json"]["filters"]["dateRange"]["from"],
"2025-07-02T10:15:00+02:00",
)
self.assertEqual(
request_json_mock.call_args_list[1].kwargs["json"]["filters"]["dateRange"]["to"],
"2025-07-02T11:15:00+02:00",
)

def test_permissions_client(self):
client = PermissionsClient(self.http)
Expand Down Expand Up @@ -333,25 +370,54 @@ async def test_async_clients(self):
await sessions.get_session_upo("ref", "upo", access_token="token")

invoices = AsyncInvoicesClient(http)
query_payload = {
"subjectType": "Subject1",
"dateRange": {
"dateType": "Issue",
"from": "2025-01-02T10:15:00",
"to": "2025-01-02T11:15:00",
},
}
export_payload = {
"encryption": {"encryptedSymmetricKey": "abc", "initializationVector": "def"},
"filters": {
"subjectType": "Subject1",
"dateRange": {
"dateType": "Issue",
"from": "2025-07-02T10:15:00",
"to": "2025-07-02T11:15:00",
},
},
}
with (
patch.object(invoices, "_request_raw", AsyncMock(return_value=response)),
patch.object(invoices, "_request_json", AsyncMock(return_value={"ok": True})),
patch.object(
invoices, "_request_json", AsyncMock(return_value={"ok": True})
) as request_json_mock,
patch.object(invoices, "_request_bytes", AsyncMock(return_value=b"bytes")),
):
await invoices.get_invoice("ksef", access_token="token")
await invoices.get_invoice_bytes("ksef", access_token="token")
await invoices.query_invoice_metadata(
{"a": 1},
query_payload,
access_token="token",
page_offset=0,
page_size=10,
sort_order="asc",
)
await invoices.export_invoices({"a": 1}, access_token="token")
await invoices.export_invoices(export_payload, access_token="token")
await invoices.get_export_status("ref", access_token="token")
await invoices.download_export_part("https://example.com")
await invoices.download_package_part("https://example.com")
await invoices.download_export_part_with_hash("https://example.com")
self.assertEqual(
request_json_mock.call_args_list[0].kwargs["json"]["dateRange"]["from"],
"2025-01-02T10:15:00+01:00",
)
self.assertEqual(
request_json_mock.call_args_list[1].kwargs["json"]["filters"]["dateRange"]["from"],
"2025-07-02T10:15:00+02:00",
)

permissions = AsyncPermissionsClient(http)
with patch.object(permissions, "_request_json", AsyncMock(return_value={"ok": True})):
Expand Down
44 changes: 43 additions & 1 deletion tests/test_openapi_models.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import json
import unittest
from pathlib import Path

from ksef_client import openapi_models as m

Expand Down Expand Up @@ -55,14 +57,18 @@ def test_invoice_status_extensions(self):
payload = {
"code": 200,
"description": "ok",
"extensions": {"x": None, "y": "1"},
"extensions": {"x": None, "y": "1", "nested": {"a": 1}, "flag": True},
}
info = m.InvoiceStatusInfo.from_dict(payload)
self.assertIsNotNone(info.extensions)
assert info.extensions is not None
self.assertEqual(info.extensions["y"], "1")
self.assertEqual(info.extensions["nested"], {"a": 1})
self.assertTrue(info.extensions["flag"])
serialized = info.to_dict()
self.assertIn("extensions", serialized)
self.assertEqual(serialized["extensions"]["nested"], {"a": 1})
self.assertTrue(serialized["extensions"]["flag"])
serialized_all = info.to_dict(omit_none=False)
self.assertIn("details", serialized_all)

Expand All @@ -74,6 +80,42 @@ def test_field_mapping(self):
self.assertEqual(parsed.to, 20.0)
self.assertIn("from", parsed.to_dict())

def test_token_permission_type_contains_introspection(self):
values = {item.value for item in m.TokenPermissionType}
self.assertIn("Introspection", values)

def test_token_permission_type_matches_openapi_when_available(self):
repo_root = Path(__file__).resolve().parents[2]
openapi_path = repo_root / "ksef-docs" / "open-api.json"
if not openapi_path.exists():
self.skipTest(
"open-api.json not found; enum compatibility test requires monorepo layout"
)

spec = json.loads(openapi_path.read_text(encoding="utf-8"))
expected = set(spec["components"]["schemas"]["TokenPermissionType"]["enum"])
actual = {item.value for item in m.TokenPermissionType}
self.assertSetEqual(actual, expected)

def test_part_upload_request_headers_keep_non_string_values(self):
payload = {
"headers": {
"X-Request-Id": "abc",
"X-Retry-After": 2,
"X-Meta": {"source": "ksef"},
"X-Enabled": True,
},
"method": "PUT",
"ordinalNumber": 1,
"url": "https://example",
}
parsed = m.PartUploadRequest.from_dict(payload)
self.assertEqual(parsed.headers["X-Request-Id"], "abc")
self.assertEqual(parsed.headers["X-Retry-After"], 2)
self.assertEqual(parsed.headers["X-Meta"], {"source": "ksef"})
self.assertTrue(parsed.headers["X-Enabled"])
self.assertEqual(parsed.to_dict()["headers"], payload["headers"])


if __name__ == "__main__":
unittest.main()
Loading