diff --git a/README.md b/README.md index 0dee91b..5712c3a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/README.md b/docs/README.md index 08b8cba..93bd9f3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 diff --git a/docs/api/invoices.md b/docs/api/invoices.md index 76053cc..0273459 100644 --- a/docs/api/invoices.md +++ b/docs/api/invoices.md @@ -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. diff --git a/docs/cli/README.md b/docs/cli/README.md index c1de9a6..998901d 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -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` @@ -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` @@ -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 diff --git a/docs/workflows/batch-session.md b/docs/workflows/batch-session.md index a703961..1b32b0c 100644 --- a/docs/workflows/batch-session.md +++ b/docs/workflows/batch-session.md @@ -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`). diff --git a/docs/workflows/export.md b/docs/workflows/export.md index 6178649..6210753 100644 --- a/docs/workflows/export.md +++ b/docs/workflows/export.md @@ -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": { @@ -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`. diff --git a/docs/workflows/online-session.md b/docs/workflows/online-session.md index 2240a71..1f9d5e3 100644 --- a/docs/workflows/online-session.md +++ b/docs/workflows/online-session.md @@ -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()` diff --git a/src/ksef_client/cli/commands/export_cmd.py b/src/ksef_client/cli/commands/export_cmd.py index e8dc5bf..8c2028d 100644 --- a/src/ksef_client/cli/commands/export_cmd.py +++ b/src/ksef_client/cli/commands/export_cmd.py @@ -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." ), @@ -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, diff --git a/src/ksef_client/cli/commands/send_cmd.py b/src/ksef_client/cli/commands/send_cmd.py index 9442bef..8f8c35e 100644 --- a/src/ksef_client/cli/commands/send_cmd.py +++ b/src/ksef_client/cli/commands/send_cmd.py @@ -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." @@ -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( diff --git a/src/ksef_client/cli/sdk/adapters.py b/src/ksef_client/cli/sdk/adapters.py index cabac93..7112263 100644 --- a/src/ksef_client/cli/sdk/adapters.py +++ b/src/ksef_client/cli/sdk/adapters.py @@ -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, } @@ -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, @@ -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": { @@ -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, diff --git a/src/ksef_client/openapi_models.py b/src/ksef_client/openapi_models.py index ada7ffa..1662186 100644 --- a/src/ksef_client/openapi_models.py +++ b/src/ksef_client/openapi_models.py @@ -445,6 +445,7 @@ class InvoiceQueryFormType(Enum): FA = "FA" PEF = "PEF" RR = "RR" + FA_RR = "FA_RR" class InvoiceQuerySubjectType(Enum): SUBJECT1 = "Subject1" @@ -1203,6 +1204,7 @@ class InitTokenAuthenticationRequest(OpenApiModel): class InvoiceExportRequest(OpenApiModel): encryption: EncryptionInfo filters: InvoiceQueryFilters + onlyMetadata: Optional[bool] = None @dataclass(frozen=True) class InvoiceExportStatusResponse(OpenApiModel): diff --git a/tests/cli/integration/test_export_run.py b/tests/cli/integration/test_export_run.py index 38419e8..7f792e0 100644 --- a/tests/cli/integration/test_export_run.py +++ b/tests/cli/integration/test_export_run.py @@ -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, diff --git a/tests/cli/unit/test_sdk_adapters.py b/tests/cli/unit/test_sdk_adapters.py index e82f4c7..7eb0a56 100644 --- a/tests/cli/unit/test_sdk_adapters.py +++ b/tests/cli/unit/test_sdk_adapters.py @@ -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")) @@ -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): @@ -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") == "" +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): diff --git a/tests/test_clients.py b/tests/test_clients.py index b23f08a..6900a3b 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -175,6 +175,7 @@ def test_invoices_client(self): } export_payload = { "encryption": {"encryptedSymmetricKey": "abc", "initializationVector": "def"}, + "onlyMetadata": True, "filters": { "subjectType": "Subject1", "dateRange": { @@ -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) @@ -541,6 +543,7 @@ async def test_async_clients(self): } export_payload = { "encryption": {"encryptedSymmetricKey": "abc", "initializationVector": "def"}, + "onlyMetadata": True, "filters": { "subjectType": "Subject1", "dateRange": { @@ -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})): diff --git a/tests/test_openapi_models.py b/tests/test_openapi_models.py index 71c67f9..24124bc 100644 --- a/tests/test_openapi_models.py +++ b/tests/test_openapi_models.py @@ -81,6 +81,30 @@ def test_field_mapping(self): self.assertEqual(parsed.to, 20.0) self.assertIn("from", parsed.to_dict()) + def test_invoice_query_form_type_contains_fa_rr(self): + values = {item.value for item in m.InvoiceQueryFormType} + self.assertIn("FA_RR", values) + + def test_invoice_export_request_supports_only_metadata(self): + payload = { + "encryption": { + "encryptedSymmetricKey": "enc", + "initializationVector": "iv", + }, + "filters": { + "subjectType": "Subject1", + "dateRange": { + "dateType": "Issue", + "from": "2026-01-01T00:00:00Z", + "to": "2026-01-31T23:59:59Z", + }, + }, + "onlyMetadata": True, + } + parsed = m.InvoiceExportRequest.from_dict(payload) + self.assertTrue(parsed.onlyMetadata) + self.assertTrue(parsed.to_dict()["onlyMetadata"]) + def test_token_permission_type_contains_introspection(self): values = {item.value for item in m.TokenPermissionType} self.assertIn("Introspection", values)