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 @@ Projekt odwzorowuje oficjalne przepływy KSeF i zapewnia spójny model pracy w d

## 🔄 Kompatybilność

Aktualna kompatybilność: **KSeF API `v2.2.1`** ([api-changelog.md](https://github.com/CIRFMF/ksef-docs/blob/2.2.1/api-changelog.md)).
Aktualna kompatybilność: **KSeF API `v2.3.0`** ([api-changelog.md](https://github.com/CIRFMF/ksef-docs/blob/2.3.0/api-changelog.md)).

## 🧭 Spis treś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.2.1`**.
Kompatybilność SDK: **KSeF API `v2.3.0`**.

## Wymagania

Expand Down
3 changes: 3 additions & 0 deletions docs/api/invoices.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ Wymagane minimum w `request_payload`:
- `encryption.initializationVector`
- `filters` (np. `subjectType` + `dateRange`)

Opcjonalnie:
- `onlyMetadata=True` – eksport zwraca wyłącznie `_metadata.json` bez XML faktur.

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

Expand Down
6 changes: 6 additions & 0 deletions docs/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,7 @@ Uwagi:
- `--save-upo` wymaga `--wait-upo`.
- `--save-upo` bez rozszerzenia jest traktowane jako sciezka pliku.
- `--save-upo-overwrite` pozwala nadpisac istniejacy plik UPO wskazany przez `--save-upo`.
- Dla `FA_RR (1)` z `--schema-version 1-1E` uzyj `--form-value FA_RR`; CLI normalizuje historyczne `RR` do `FA_RR`.

## `ksef send batch`

Expand All @@ -408,6 +409,7 @@ Options:
Walidacja:
- dokladnie jedno z `--zip` albo `--dir`.
- `--save-upo-overwrite` pozwala nadpisac istniejacy plik UPO wskazany przez `--save-upo`.
- Dla `FA_RR (1)` z `--schema-version 1-1E` uzyj `--form-value FA_RR`; CLI normalizuje historyczne `RR` do `FA_RR`.

## `ksef upo get`

Expand Down Expand Up @@ -456,12 +458,16 @@ Options:
--from TEXT
--to TEXT
--subject-type TEXT [default: Subject1]
--only-metadata
--poll-interval FLOAT [default: 2.0]
--max-attempts INTEGER [default: 120]
--out TEXT [required]
--base-url TEXT
```

Uwagi:
- `--only-metadata` pobiera tylko `_metadata.json` bez XML faktur.

## Exit codes

- `0` sukces
Expand Down
1 change: 1 addition & 0 deletions docs/workflows/batch-session.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ print(session_ref)
## Uwagi

- Wysyłka partów odbywa się na pre-signed URL: **bez Bearer tokena** (workflow wykonuje wywołania z `skip_auth=True`).
- Dla `FA_RR (1)` w wersji `1-1E` przekazuj `formCode.value="FA_RR"` zamiast `RR`.
- Limit czasu wysyłki w sesji wsadowej wynosi **liczba partów × 20 minut na każdy part**; liczba partów wpływa bezpośrednio na czas dostępny na wysyłkę.
- Podział ZIP musi nastąpić **przed szyfrowaniem** (biblioteka wykonuje to w `encrypt_batch_parts()`).
- Korelacja statusów z plikami źródłowymi jest możliwa przez zapisanie hashy SHA-256 faktur (przed szyfrowaniem) i mapowanie ich na identyfikatory w procesie weryfikacji (`invoiceHash`).
2 changes: 2 additions & 0 deletions docs/workflows/export.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ with KsefClient(KsefClientOptions(base_url=KsefEnvironment.DEMO.value)) as clien
"encryptedSymmetricKey": encryption.encryption_info.encrypted_symmetric_key,
"initializationVector": encryption.encryption_info.initialization_vector,
},
"onlyMetadata": False,
"filters": {
"subjectType": "Subject1",
"dateRange": {
Expand Down Expand Up @@ -70,6 +71,7 @@ print(len(result.metadata_summaries), len(result.invoice_xml_files))
## Uwagi

- Części paczki są dostępne pod `package.parts[].url` i są pobierane **bez Bearer tokena** (pre-signed URL).
- Ustaw `onlyMetadata=True`, jeśli potrzebujesz wyłącznie `_metadata.json` bez XML faktur.
- Dla każdego pobranego (zaszyfrowanego) partu workflow liczy hash `SHA-256` (base64) i porównuje z `x-ms-meta-hash`, jeśli nagłówek jest obecny.
- Domyślnie (`KsefClientOptions.require_export_part_hash=True`) brak `x-ms-meta-hash` powoduje `ValueError`.
- Niezgodność hash (`x-ms-meta-hash` vs. wyliczony hash) zawsze powoduje `ValueError`.
Expand Down
1 change: 1 addition & 0 deletions docs/workflows/online-session.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ print("OK")
## Uwagi

- `encryption_data` z `open_session()` musi być użyte dla wszystkich faktur wysyłanych w ramach sesji.
- Dla `FA_RR (1)` w wersji `1-1E` przekazuj `formCode.value="FA_RR"` zamiast `RR`.
- Status przetwarzania jest udostępniany asynchronicznie; sprawdzanie statusu odbywa się przez polling.
- Pobranie UPO:
- dla faktury: `get_session_invoice_upo_by_ref()` / `...by_ksef()`
Expand Down
6 changes: 6 additions & 0 deletions src/ksef_client/cli/commands/export_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ def export_run(
subject_type: str = typer.Option(
"Subject1", "--subject-type", help="KSeF subject type filter."
),
only_metadata: bool = typer.Option(
False,
"--only-metadata",
help="Export only _metadata.json without invoice XML files.",
),
poll_interval: float = typer.Option(
2.0, "--poll-interval", help="Polling interval in seconds."
),
Expand All @@ -89,6 +94,7 @@ def export_run(
date_from=date_from,
date_to=date_to,
subject_type=subject_type,
only_metadata=only_metadata,
poll_interval=poll_interval,
max_attempts=max_attempts,
out=out,
Expand Down
12 changes: 10 additions & 2 deletions src/ksef_client/cli/commands/send_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,11 @@ def send_online(
invoice: str = typer.Option(..., "--invoice", help="Path to invoice XML file."),
system_code: str = typer.Option("FA (3)", "--system-code", help="Form code systemCode."),
schema_version: str = typer.Option("1-0E", "--schema-version", help="Form code schemaVersion."),
form_value: str = typer.Option("FA", "--form-value", help="Form code value."),
form_value: str = typer.Option(
"FA",
"--form-value",
help="Form code value (use FA_RR for FA_RR (1) 1-1E).",
),
upo_v43: bool = typer.Option(False, "--upo-v43", help="Request UPO v4.3 format."),
wait_status: bool = typer.Option(
False, "--wait-status", help="Wait until invoice processing status is final."
Expand Down Expand Up @@ -125,7 +129,11 @@ def send_batch(
),
system_code: str = typer.Option("FA (3)", "--system-code", help="Form code systemCode."),
schema_version: str = typer.Option("1-0E", "--schema-version", help="Form code schemaVersion."),
form_value: str = typer.Option("FA", "--form-value", help="Form code value."),
form_value: str = typer.Option(
"FA",
"--form-value",
help="Form code value (use FA_RR for FA_RR (1) 1-1E).",
),
parallelism: int = typer.Option(4, "--parallelism", help="Parallel upload worker count."),
upo_v43: bool = typer.Option(False, "--upo-v43", help="Request UPO v4.3 format."),
wait_status: bool = typer.Option(
Expand Down
22 changes: 18 additions & 4 deletions src/ksef_client/cli/sdk/adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,16 +150,27 @@ def _select_certificate(certs: list[dict[str, Any]], usage_name: str) -> str:


def _build_form_code(system_code: str, schema_version: str, form_value: str) -> dict[str, str]:
if not system_code.strip() or not schema_version.strip() or not form_value.strip():
normalized_system_code = system_code.strip()
normalized_schema_version = schema_version.strip()
normalized_form_value = form_value.strip()
if not normalized_system_code or not normalized_schema_version or not normalized_form_value:
raise CliError(
"Invalid form code options.",
ExitCode.VALIDATION_ERROR,
"Use non-empty --system-code, --schema-version and --form-value.",
)

if (
normalized_system_code == "FA_RR (1)"
and normalized_schema_version == "1-1E"
and normalized_form_value == "RR"
):
normalized_form_value = "FA_RR"

return {
"systemCode": system_code.strip(),
"schemaVersion": schema_version.strip(),
"value": form_value.strip(),
"systemCode": normalized_system_code,
"schemaVersion": normalized_schema_version,
"value": normalized_form_value,
}


Expand Down Expand Up @@ -983,6 +994,7 @@ def run_export(
date_from: str | None,
date_to: str | None,
subject_type: str,
only_metadata: bool = False,
poll_interval: float,
max_attempts: int,
out: str,
Expand All @@ -1002,6 +1014,7 @@ def run_export(
"encryptedSymmetricKey": encryption.encryption_info.encrypted_symmetric_key,
"initializationVector": encryption.encryption_info.initialization_vector,
},
"onlyMetadata": only_metadata,
"filters": {
"subjectType": subject_type,
"dateRange": {
Expand Down Expand Up @@ -1060,6 +1073,7 @@ def run_export(
"metadata_file": str(metadata_path),
"metadata_count": len(processed.metadata_summaries),
"xml_files_count": files_saved,
"only_metadata": only_metadata,
"out_dir": str(out_dir),
"from": from_iso,
"to": to_iso,
Expand Down
2 changes: 2 additions & 0 deletions src/ksef_client/openapi_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,7 @@ class InvoiceQueryFormType(Enum):
FA = "FA"
PEF = "PEF"
RR = "RR"
FA_RR = "FA_RR"

class InvoiceQuerySubjectType(Enum):
SUBJECT1 = "Subject1"
Expand Down Expand Up @@ -1203,6 +1204,7 @@ class InitTokenAuthenticationRequest(OpenApiModel):
class InvoiceExportRequest(OpenApiModel):
encryption: EncryptionInfo
filters: InvoiceQueryFilters
onlyMetadata: Optional[bool] = None

@dataclass(frozen=True)
class InvoiceExportStatusResponse(OpenApiModel):
Expand Down
13 changes: 13 additions & 0 deletions tests/cli/integration/test_export_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,19 @@ def test_export_run_json_success(runner, monkeypatch, tmp_path) -> None:
assert payload["command"] == "export.run"


def test_export_run_only_metadata_flag(runner, monkeypatch, tmp_path) -> None:
seen: dict[str, object] = {}

def _fake_run(**kwargs):
seen.update(kwargs)
return {"reference_number": "EXP-ONLY-META"}

monkeypatch.setattr(export_cmd, "run_export", _fake_run)
result = runner.invoke(app, ["export", "run", "--only-metadata", "--out", str(tmp_path)])
assert result.exit_code == 0
assert seen["only_metadata"] is True


def test_export_run_validation_error_exit(runner, monkeypatch, tmp_path) -> None:
monkeypatch.setattr(
export_cmd,
Expand Down
89 changes: 88 additions & 1 deletion tests/cli/unit/test_sdk_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -1187,6 +1187,11 @@ def test_build_form_code_validation() -> None:
assert exc.value.code == ExitCode.VALIDATION_ERROR


def test_build_form_code_normalizes_fa_rr_1_1e_value() -> None:
form_code = adapters._build_form_code("FA_RR (1)", "1-1E", "RR")
assert form_code["value"] == "FA_RR"


def test_load_invoice_xml_missing_and_empty(tmp_path) -> None:
with pytest.raises(CliError) as missing:
adapters._load_invoice_xml(str(tmp_path / "nope.xml"))
Expand Down Expand Up @@ -1941,13 +1946,16 @@ def get_export_status(self, reference_number, access_token):


def test_run_export_success(monkeypatch, tmp_path) -> None:
seen: dict[str, object] = {}

class _Security:
def get_public_key_certificates(self):
return [{"usage": ["SymmetricKeyEncryption"], "certificate": "CERT"}]

class _Invoices:
def export_invoices(self, payload, access_token):
_ = (payload, access_token)
seen["payload"] = payload
seen["access_token"] = access_token
return {"referenceNumber": "EXP-OK"}

def get_export_status(self, reference_number, access_token):
Expand Down Expand Up @@ -1998,10 +2006,89 @@ def download_and_process_package(self, package, encryption_data):
)
assert result["reference_number"] == "EXP-OK"
assert result["metadata_count"] == 1
assert result["only_metadata"] is False
payload = seen["payload"]
assert isinstance(payload, dict)
assert payload["onlyMetadata"] is False
assert (tmp_path / "_metadata.json").exists()
assert (tmp_path / "a.xml").read_text(encoding="utf-8") == "<xml/>"


def test_run_export_only_metadata_success(monkeypatch, tmp_path) -> None:
seen: dict[str, object] = {}

class _Security:
def get_public_key_certificates(self):
return [{"usage": ["SymmetricKeyEncryption"], "certificate": "CERT"}]

class _Invoices:
def export_invoices(self, payload, access_token):
seen["payload"] = payload
seen["access_token"] = access_token
return {"referenceNumber": "EXP-META"}

def get_export_status(self, reference_number, access_token):
_ = (reference_number, access_token)
return {
"status": {"code": 200, "description": "Done"},
"package": {"parts": [{"url": "https://example.com", "method": "GET"}]},
}

class _FakeExportWorkflow:
def __init__(self, invoices, http_client):
_ = (invoices, http_client)

def download_and_process_package(self, package, encryption):
_ = (package, encryption)
return SimpleNamespace(
metadata_summaries=[{"ksefNumber": "KSEF-1"}],
invoice_xml_files={},
)

fake_encryption = SimpleNamespace(
key=b"k",
iv=b"i",
encryption_info=SimpleNamespace(
encrypted_symmetric_key="enc",
initialization_vector="iv",
),
)

monkeypatch.setattr(adapters, "get_tokens", lambda profile: ("acc", "ref"))
monkeypatch.setattr(
adapters,
"create_client",
lambda base_url, access_token=None: _FakeClient(
invoices=_Invoices(), security=_Security(), http_client=SimpleNamespace()
),
)
monkeypatch.setattr(adapters, "build_encryption_data", lambda cert: fake_encryption)
monkeypatch.setattr(adapters, "ExportWorkflow", _FakeExportWorkflow)
monkeypatch.setattr(adapters.time, "sleep", lambda _: None)

result = adapters.run_export(
profile="demo",
base_url="https://example.invalid",
date_from="2026-01-01",
date_to="2026-01-31",
subject_type="Subject1",
only_metadata=True,
poll_interval=0.01,
max_attempts=2,
out=str(tmp_path),
)

assert result["reference_number"] == "EXP-META"
assert result["metadata_count"] == 1
assert result["xml_files_count"] == 0
assert result["only_metadata"] is True
payload = seen["payload"]
assert isinstance(payload, dict)
assert payload["onlyMetadata"] is True
assert (tmp_path / "_metadata.json").exists()
assert list(tmp_path.glob("*.xml")) == []


def test_run_export_missing_reference_and_package(monkeypatch, tmp_path) -> None:
class _Security:
def get_public_key_certificates(self):
Expand Down
4 changes: 4 additions & 0 deletions tests/test_clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ def test_invoices_client(self):
}
export_payload = {
"encryption": {"encryptedSymmetricKey": "abc", "initializationVector": "def"},
"onlyMetadata": True,
"filters": {
"subjectType": "Subject1",
"dateRange": {
Expand Down Expand Up @@ -224,6 +225,7 @@ def test_invoices_client(self):
request_json_mock.call_args_list[1].kwargs["json"]["filters"]["dateRange"]["to"],
"2025-07-02T11:15:00+02:00",
)
self.assertTrue(request_json_mock.call_args_list[1].kwargs["json"]["onlyMetadata"])

def test_invoices_client_query_metadata_without_optional_params(self):
client = InvoicesClient(self.http)
Expand Down Expand Up @@ -541,6 +543,7 @@ async def test_async_clients(self):
}
export_payload = {
"encryption": {"encryptedSymmetricKey": "abc", "initializationVector": "def"},
"onlyMetadata": True,
"filters": {
"subjectType": "Subject1",
"dateRange": {
Expand Down Expand Up @@ -579,6 +582,7 @@ async def test_async_clients(self):
request_json_mock.call_args_list[1].kwargs["json"]["filters"]["dateRange"]["from"],
"2025-07-02T10:15:00+02:00",
)
self.assertTrue(request_json_mock.call_args_list[1].kwargs["json"]["onlyMetadata"])

permissions = AsyncPermissionsClient(http)
with patch.object(permissions, "_request_json", AsyncMock(return_value={"ok": True})):
Expand Down
Loading
Loading