From a659debdbc7224a5817d4310cdc0e583fb25f542 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20Podsiad=C5=82y?= Date: Tue, 23 Jun 2026 11:14:22 +0200 Subject: [PATCH 01/11] chore: remove plans --- plans/01-1.0-readiness.md | 118 -------------------------------------- plans/README.md | 15 ----- 2 files changed, 133 deletions(-) delete mode 100644 plans/01-1.0-readiness.md delete mode 100644 plans/README.md diff --git a/plans/01-1.0-readiness.md b/plans/01-1.0-readiness.md deleted file mode 100644 index 0a161b8..0000000 --- a/plans/01-1.0-readiness.md +++ /dev/null @@ -1,118 +0,0 @@ -# 1.0 readiness checklist - -## Current status - -The original pre-1.0 hardening roadmap is mostly closed: - -- sync export package part names use one shared sanitizer; -- sync and async error/endpoint helpers share one implementation; -- root package exports the public exception surface and `__version__`; -- auth/token polling timeouts use SDK timeout exceptions, not fake HTTP status codes; -- async session factories support `async with auth.online_session(...)`; -- domain query parameter types no longer import from endpoint modules; -- sync/async parity is enforced by tests; -- sync modules are generated from async sources and guarded by `check-gen-sync`; -- the low-level API is documented and tested through `client.raw` / `auth.raw`; -- stable public import paths and root exports are documented; -- error handling is documented for SDK users; -- sync code generation is documented for contributors; -- legacy sample XML helpers and assets are deleted; -- the FA(3) invoice builder and builder-owned examples/tests/docs are deleted - locally; -- CI and PyPI publishing both use the same local release gate. - -The local release gate currently is: - -```bash -just release-check -``` - -It runs lint, format, OpenAPI version drift check, generated-sync drift check, -typecheck, unit tests, runtime-check tests, and package build. - -## Must be true before tagging 1.0.0 - -- [x] `just release-check` passes on a clean checkout after the release branch - is committed. -- [x] GitHub CI passes on Python 3.12 and 3.13 for the committed release branch. -- [x] The PyPI tag workflow runs `just release-check` before publishing. -- [ ] The package version, changelog, README, and docs all describe the same - `1.0.0` release. -- [x] `README.md` and PyPI long description describe KSeF OpenAPI `2.6.1` or - the current checked OpenAPI version from `openapi.json`. -- [x] `npm run sync-docs -- --only sdk` has been run in the docs repository - after SDK docs changes. -- [x] The docs repository builds with the synced SDK docs. -- [x] No untracked release files remain in the SDK or docs repositories. - -## Current evidence - -- `just release-check` passed locally on 2026-06-23 after the legacy sample XML - asset cleanup. -- `just release-check` passed locally on 2026-06-23 after moving the readiness - branch from already-tagged `0.18.1` to `0.19.0`; the package build produced - `dist/ksef2-0.19.0.tar.gz` and `dist/ksef2-0.19.0-py3-none-any.whl`. -- `just release-check` passed locally on 2026-06-23 after deleting the FA(3) - invoice builder, builder examples, generated sample-invoice helpers, and - builder-focused tests. -- The 2026-06-23 local `dist/ksef2-0.19.0.*` artifacts do not contain - `ksef2.fa3`, `services/builders`, `fa3/drafts.py`, builder docs, sample - invoice helpers, `KsefInvoiceDraft`, or `DraftIntent`. -- `v0.18.1` already points at the README-only release tag, and no `v0.19.0` - tag exists yet. -- PyPI latest is still `0.18.1`; the local readiness branch is staged as - `0.19.0`, not `1.0.0`. -- The batch PDF export example test passed locally on 2026-06-23 against the - canonical `schemas/FA3/samples` directory. -- GitHub PR #78 passed remote `ci (3.12)` and `ci (3.13)` checks on - 2026-06-22 for commit `99150e4`. These checks do not include the later local - FA(3) builder deletion. -- GitHub PR #78 passed remote `ci (3.12)`, `ci (3.13)`, docs validation, and - test coverage checks on 2026-06-23 for commit `aa72319`, which includes the - FA(3) builder deletion. -- `just integration` passed locally on 2026-06-22: 134 TEST-environment tests. -- `just e2e` passed locally on 2026-06-22: 21 TEST-environment example tests. -- Integration and e2e tests must be repeated after the FA(3) builder deletion. - Invoice-submission examples now require caller-provided invoice XML through - `KSEF2_EXAMPLE_INVOICE_XML` / `KSEF2_EXAMPLE_SELLER_NIP`, and integration - invoice tests use `KSEF2_TEST_INVOICE_XML` plus seller/buyer NIP variables. -- `npm run sync-docs -- --only sdk` and `npm run build` passed in the docs - repository on 2026-06-22. -- GitHub docs PR #1 passed the remote `Check / build` workflow on 2026-06-23 - for commit `37bc2f3`; the docs default SDK source now follows `main` and the - sidebar filters links absent from the synced SDK ref. -- The docs assembly repository was updated locally on 2026-06-23 to remove - hard-coded FA(3) builder/sidebar/API-reference generator targets. - `npm run sync-docs -- --only sdk` and `npm run build` both passed after that - cleanup. -- GitHub docs PR #1 passed the remote `Check / build` workflow on 2026-06-23 - for commit `8b5f73b`, which includes the docs assembly FA(3) builder cleanup. -- `.github/workflows/ci.yml` runs `just release-check` on Python 3.12 and 3.13 - with `UV_PYTHON` set from the matrix. -- `.github/workflows/publish.yml` runs `just release-check` before publishing to - PyPI. - -## Remaining 1.0 candidates - -### Release process - -- Keep OpenAPI version drift checked in `just release-check`. -- Keep tag publishing behind the same release gate as local releases. -- Keep `plans/` in the repository through 1.0 so the release checklist stays - reviewable with the release branch. -- Bump from the temporary `0.19.0` readiness branch version to `1.0.0` only - after the public API surface, docs, and release evidence are final. -- Ensure the final release tag is cut from a clean working tree after PR #78 and - docs PR #1 are merged. - -### Integration confidence - -- Repeat TEST-environment integration tests immediately before tagging 1.0.0 - when credentials are available: - - ```bash - just integration - just e2e - ``` - -- Record any flaky external KSeF behavior separately from SDK regressions. diff --git a/plans/README.md b/plans/README.md deleted file mode 100644 index 41366d4..0000000 --- a/plans/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# ksef2 SDK 1.0 readiness - -This directory tracks the remaining work to move the SDK from the current -`0.x` line into a stable `1.0.0` release. - -The previous per-workstream hardening plans were useful while the sync/async, -error-handling, and code-generation refactors were still open. They are now -replaced by the current readiness checklist in -[`01-1.0-readiness.md`](01-1.0-readiness.md). - -Use this checklist as the release source of truth: - -- keep every item tied to a verification command or file-level evidence; -- mark an item done only after the evidence exists in the current tree; -- add new 1.0 blockers here instead of leaving them in chat history. From 8368b949bab65ccb257b1caf32c0812dd301dfb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20Podsiad=C5=82y?= Date: Sun, 28 Jun 2026 22:23:01 +0200 Subject: [PATCH 02/11] chore: update package metadata --- LICENSE.md | 2 +- justfile | 5 +---- pyproject.toml | 2 ++ 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/LICENSE.md b/LICENSE.md index 8aa2645..72eb0a0 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,6 @@ MIT License -Copyright (c) [year] [fullname] +Copyright (c) 2026 Stacking Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/justfile b/justfile index 7f2f8df..ffedd9e 100644 --- a/justfile +++ b/justfile @@ -52,10 +52,7 @@ check-gen-sync: uv run python scripts/gen_sync.py --check typecheck: - #!/usr/bin/env bash - output=$(uv run --extra runtime-checks basedpyright --level error 2>&1) - echo "$output" - echo "$output" | grep -q "0 errors" + uv run --extra runtime-checks basedpyright --level error uv run --extra runtime-checks basedpyright scripts/gen_sync.py diff --git a/pyproject.toml b/pyproject.toml index eb2b701..1f0bc9e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,8 @@ name = "ksef2" version = "0.19.0" description = "Python SDK and Tools for Poland's KSeF (Krajowy System e-Faktur) API" readme = "README.md" +authors = [{ name = "Artur Podsiadły" }] +license = { file = "LICENSE.md" } requires-python = ">=3.12" pythonpath = "src" dependencies = [ From b8369f81562f0e8451b644ee9a9c8e00e4e5fcaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20Podsiad=C5=82y?= Date: Sun, 28 Jun 2026 22:23:41 +0200 Subject: [PATCH 03/11] feat: add refreshable certificate store --- src/ksef2/__init__.py | 3 + src/ksef2/core/stores.py | 68 ++++++++++++++++--- src/ksef2/core/tools.py | 2 - src/ksef2/core/xades.py | 6 +- src/ksef2/renderers/__init__.py | 9 +++ src/ksef2/services/renderers/xslt.py | 26 +++++++- tests/unit/core/test_stores.py | 80 +++++++++++++++++++++++ tests/unit/services/test_pdf_renderer.py | 4 +- tests/unit/services/test_xslt_renderer.py | 73 +++++++++++++++++++++ 9 files changed, 252 insertions(+), 19 deletions(-) create mode 100644 src/ksef2/renderers/__init__.py create mode 100644 tests/unit/core/test_stores.py create mode 100644 tests/unit/services/test_xslt_renderer.py diff --git a/src/ksef2/__init__.py b/src/ksef2/__init__.py index 5c5d027..ca44272 100644 --- a/src/ksef2/__init__.py +++ b/src/ksef2/__init__.py @@ -71,9 +71,12 @@ def _enable_runtime_checks() -> None: KSeFValidationError, NoCertificateAvailableError, ) +from ksef2.core.stores import CertificateStore, CertificateStoreProtocol __all__ = [ "AsyncClient", + "CertificateStore", + "CertificateStoreProtocol", "Client", "ConnectionPoolConfig", "Environment", diff --git a/src/ksef2/core/stores.py b/src/ksef2/core/stores.py index 00b469e..22efa81 100644 --- a/src/ksef2/core/stores.py +++ b/src/ksef2/core/stores.py @@ -1,21 +1,59 @@ from collections.abc import Iterable -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone +from typing import Protocol, runtime_checkable from ksef2.core import exceptions from ksef2.domain.models import encryption +DEFAULT_CERTIFICATE_REFRESH_AFTER = timedelta(hours=24) + + +def _make_aware(dt: datetime, tz: timezone = timezone.utc) -> datetime: + """Convert naive datetime to aware, or return if already aware.""" + if dt.tzinfo is None: + return dt.replace(tzinfo=tz) + return dt + + +@runtime_checkable +class CertificateStoreProtocol(Protocol): + """Structural contract for SDK public encryption certificate stores.""" + + def load(self, certs: Iterable[encryption.PublicKeyCertificate]) -> None: ... + + def get_valid( + self, + usage: encryption.CertUsage | encryption.CertUsageEnum | str, + ) -> encryption.PublicKeyCertificate: ... + + def needs_refresh( + self, + usage: encryption.CertUsage | encryption.CertUsageEnum | str, + *, + at: datetime | None = None, + ) -> bool: ... + class CertificateStore: - def __init__(self) -> None: + def __init__( + self, + refresh_after: timedelta | None = DEFAULT_CERTIFICATE_REFRESH_AFTER, + ) -> None: + if refresh_after is not None and refresh_after < timedelta(0): + raise ValueError("refresh_after cannot be negative.") self._certificates: list[encryption.PublicKeyCertificate] = [] + self._refresh_after = refresh_after + self._loaded_at: datetime | None = None def load(self, certs: Iterable[encryption.PublicKeyCertificate]) -> None: """Replace stored certificates.""" self._certificates = list(certs) + self._loaded_at = datetime.now(tz=timezone.utc) def add(self, cert: encryption.PublicKeyCertificate) -> None: self._certificates.append(cert) + self._loaded_at = datetime.now(tz=timezone.utc) def all(self) -> list[encryption.PublicKeyCertificate]: return list(self._certificates) @@ -41,13 +79,7 @@ def list_valid( *, at: datetime | None = None, ) -> list[encryption.PublicKeyCertificate]: - def make_aware(dt: datetime, tz=timezone.utc) -> datetime: - """Convert naive datetime to aware, or return if already aware""" - if dt.tzinfo is None: - return dt.replace(tzinfo=tz) - return dt - - now = make_aware(at) if at else datetime.now(tz=timezone.utc) + now = _make_aware(at) if at else datetime.now(tz=timezone.utc) return [ cert @@ -65,3 +97,21 @@ def by_usage( return [ cert for cert in self.list_valid(at=at) if normalized_usage in cert.usage ] + + def needs_refresh( + self, + usage: encryption.CertUsage | encryption.CertUsageEnum | str, + *, + at: datetime | None = None, + ) -> bool: + if not self.by_usage(usage=usage, at=at): + return True + + if self._loaded_at is None: + return True + + if self._refresh_after is None: + return False + + now = _make_aware(at) if at else datetime.now(tz=timezone.utc) + return _make_aware(self._loaded_at) + self._refresh_after <= now diff --git a/src/ksef2/core/tools.py b/src/ksef2/core/tools.py index 8b10ccb..00793af 100644 --- a/src/ksef2/core/tools.py +++ b/src/ksef2/core/tools.py @@ -1,7 +1,5 @@ """Generators for valid Polish NIP and PESEL numbers (for testing).""" -from __future__ import annotations - import random NIP_WEIGHTS = (6, 5, 7, 2, 3, 4, 5, 6, 7) diff --git a/src/ksef2/core/xades.py b/src/ksef2/core/xades.py index 5e88227..d750f8b 100644 --- a/src/ksef2/core/xades.py +++ b/src/ksef2/core/xades.py @@ -1,7 +1,5 @@ """Helpers for loading certificates and creating XAdES authentication payloads.""" -from __future__ import annotations - import datetime from pathlib import Path @@ -45,7 +43,7 @@ def load_certificate_from_pem(source: bytes | str | Path) -> Certificate: Example — certificate obtained from MCU (DEMO / PRODUCTION):: - from ksef2.core.xades import load_certificate_from_pem, load_private_key_from_pem + from ksef2.xades import load_certificate_from_pem, load_private_key_from_pem from ksef2 import Client, Environment cert = load_certificate_from_pem("cert.pem") @@ -258,7 +256,7 @@ def sign_xades( class LocalSigner: - """A :class:`~ksef2.domain.interfaces.Signer` that signs XML locally.""" + """Signs XML locally with a certificate and private key.""" def __init__(self, cert: Certificate, private_key: XAdESPrivateKey) -> None: self._cert = cert diff --git a/src/ksef2/renderers/__init__.py b/src/ksef2/renderers/__init__.py new file mode 100644 index 0000000..c189864 --- /dev/null +++ b/src/ksef2/renderers/__init__.py @@ -0,0 +1,9 @@ +"""Public invoice rendering helpers.""" + +from ksef2.services.renderers.pdf import InvoicePDFExporter +from ksef2.services.renderers.xslt import InvoiceXSLTRenderer + +__all__ = [ + "InvoicePDFExporter", + "InvoiceXSLTRenderer", +] diff --git a/src/ksef2/services/renderers/xslt.py b/src/ksef2/services/renderers/xslt.py index c334d47..abbcaab 100644 --- a/src/ksef2/services/renderers/xslt.py +++ b/src/ksef2/services/renderers/xslt.py @@ -12,7 +12,13 @@ @final class InvoiceXSLTRenderer: - """Render FA3 invoice XML to HTML using the bundled XSLT stylesheet.""" + """Render FA3 invoice XML to HTML using the bundled XSLT stylesheet. + + By default, XSLT ``document()`` reads cannot access local files or the + network. Enabling code lookups allows read-only file and network access so + the bundled stylesheet can resolve schema documents for code descriptions. + Use enabled code lookups only with trusted stylesheets. + """ def __init__( self, @@ -23,6 +29,19 @@ def __init__( Path(stylesheet_path) if stylesheet_path else _DEFAULT_STYLESHEET_PATH ) self._enable_code_lookups = enable_code_lookups + read_file = enable_code_lookups + write_file = False + create_dir = False + read_network = enable_code_lookups + write_network = False + xslt_access_options = { + "read_file": read_file, + "write_file": write_file, + "create_dir": create_dir, + "read_network": read_network, + "write_network": write_network, + } + self._access_control = etree.XSLTAccessControl(**xslt_access_options) self._transform: etree.XSLT | None = None def _get_params(self) -> dict[str, str]: @@ -47,7 +66,10 @@ def _load_transform(self) -> None: ) from e try: - self._transform = etree.XSLT(xslt_doc) + self._transform = etree.XSLT( + xslt_doc, + access_control=self._access_control, + ) except etree.XSLTParseError as e: raise KSeFInvoiceRenderingError( f"Failed to compile XSLT stylesheet: {self._stylesheet_path}" diff --git a/tests/unit/core/test_stores.py b/tests/unit/core/test_stores.py new file mode 100644 index 0000000..97b0963 --- /dev/null +++ b/tests/unit/core/test_stores.py @@ -0,0 +1,80 @@ +from datetime import datetime, timedelta, timezone + +import pytest + +from ksef2.core.stores import CertificateStore +from ksef2.domain.models.encryption import CertUsage, PublicKeyCertificate + + +def _certificate( + usage: CertUsage, + *, + public_key_id: str = "public-key-id", +) -> PublicKeyCertificate: + return PublicKeyCertificate( + certificate="certificate", + certificate_id="certificate-id", + public_key_id=public_key_id, + valid_from=datetime(2025, 1, 1, tzinfo=timezone.utc), + valid_to=datetime(2030, 1, 1, tzinfo=timezone.utc), + usage=[usage], + ) + + +def test_empty_store_needs_refresh() -> None: + store = CertificateStore() + + assert store.needs_refresh("symmetric_key_encryption") + + +def test_missing_usage_needs_refresh() -> None: + store = CertificateStore() + store.load([_certificate("ksef_token_encryption")]) + + assert store.needs_refresh("symmetric_key_encryption") + + +def test_fresh_valid_certificate_does_not_need_refresh() -> None: + store = CertificateStore() + store.load([_certificate("symmetric_key_encryption")]) + + assert not store.needs_refresh("symmetric_key_encryption") + + +def test_stale_valid_certificate_needs_refresh() -> None: + store = CertificateStore() + store.load([_certificate("symmetric_key_encryption")]) + + assert store.needs_refresh( + "symmetric_key_encryption", + at=datetime.now(tz=timezone.utc) + timedelta(days=2), + ) + + +def test_refresh_after_none_keeps_valid_certificate_fresh() -> None: + store = CertificateStore(refresh_after=None) + store.load([_certificate("symmetric_key_encryption")]) + + assert not store.needs_refresh( + "symmetric_key_encryption", + at=datetime.now(tz=timezone.utc) + timedelta(days=365), + ) + + +def test_zero_refresh_interval_always_refreshes_valid_certificate() -> None: + store = CertificateStore(refresh_after=timedelta(0)) + store.load([_certificate("symmetric_key_encryption")]) + + assert store.needs_refresh("symmetric_key_encryption") + + +def test_add_marks_store_fresh() -> None: + store = CertificateStore() + store.add(_certificate("symmetric_key_encryption")) + + assert not store.needs_refresh("symmetric_key_encryption") + + +def test_negative_refresh_interval_is_rejected() -> None: + with pytest.raises(ValueError, match="refresh_after cannot be negative"): + CertificateStore(refresh_after=timedelta(seconds=-1)) diff --git a/tests/unit/services/test_pdf_renderer.py b/tests/unit/services/test_pdf_renderer.py index 94dfb8f..dacdf42 100644 --- a/tests/unit/services/test_pdf_renderer.py +++ b/tests/unit/services/test_pdf_renderer.py @@ -4,11 +4,11 @@ import pytest -from ksef2.services.renderers import InvoicePDFExporter, InvoiceXSLTRenderer +from ksef2.renderers import InvoicePDFExporter, InvoiceXSLTRenderer def test_renderers_package_imports_without_loading_weasyprint() -> None: - import ksef2.services.renderers as renderers + import ksef2.renderers as renderers assert renderers.InvoiceXSLTRenderer is InvoiceXSLTRenderer assert renderers.InvoicePDFExporter is InvoicePDFExporter diff --git a/tests/unit/services/test_xslt_renderer.py b/tests/unit/services/test_xslt_renderer.py new file mode 100644 index 0000000..84ae322 --- /dev/null +++ b/tests/unit/services/test_xslt_renderer.py @@ -0,0 +1,73 @@ +from pathlib import Path + +import pytest + +from ksef2.core.exceptions import KSeFInvoiceRenderingError +from ksef2.renderers import InvoiceXSLTRenderer + + +def write_document_lookup_stylesheet(tmp_path: Path, schema_uri: str) -> Path: + stylesheet_path = tmp_path / "lookup.xsl" + _ = stylesheet_path.write_text( + f""" + + + + + + + + + + + +""", + encoding="utf-8", + ) + return stylesheet_path + + +@pytest.mark.parametrize("schema_source", ["file", "network"]) +def test_default_xslt_access_control_denies_document_reads( + tmp_path: Path, + schema_source: str, +) -> None: + codes_path = tmp_path / "codes.xml" + _ = codes_path.write_text( + "lookup ok", + encoding="utf-8", + ) + if schema_source == "file": + schema_uri = codes_path.as_uri() + else: + schema_uri = "http://127.0.0.1:9/codes.xml" + + stylesheet_path = write_document_lookup_stylesheet(tmp_path, schema_uri) + renderer = InvoiceXSLTRenderer(stylesheet_path=stylesheet_path) + + with pytest.raises( + KSeFInvoiceRenderingError, + match="XSLT transformation failed while rendering invoice.", + ) as exc_info: + _ = renderer.render_from_string("") + + assert exc_info.value.__cause__ is not None + + +def test_enabled_code_lookups_allow_read_only_document_file_reads( + tmp_path: Path, +) -> None: + codes_path = tmp_path / "codes.xml" + _ = codes_path.write_text( + "lookup ok", + encoding="utf-8", + ) + stylesheet_path = write_document_lookup_stylesheet(tmp_path, codes_path.as_uri()) + renderer = InvoiceXSLTRenderer( + stylesheet_path=stylesheet_path, + enable_code_lookups=True, + ) + + html = renderer.render_from_string("") + + assert "lookup ok" in html From 2a224d332d7224f0f3aee011412ed7a04564eb62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20Podsiad=C5=82y?= Date: Sun, 28 Jun 2026 22:25:02 +0200 Subject: [PATCH 04/11] feat: add resumable authentication state --- scripts/examples/session/session_resume.py | 14 +- src/ksef2/clients/async_auth.py | 11 +- src/ksef2/clients/async_authenticated.py | 202 +++++++------- src/ksef2/clients/async_base.py | 27 +- src/ksef2/clients/async_batch.py | 43 ++- src/ksef2/clients/async_online.py | 24 +- src/ksef2/clients/auth.py | 11 +- src/ksef2/clients/authenticated.py | 200 +++++++------- src/ksef2/clients/base.py | 27 +- src/ksef2/clients/batch.py | 43 ++- src/ksef2/clients/online.py | 24 +- src/ksef2/domain/models/__init__.py | 63 ++++- src/ksef2/domain/models/auth.py | 71 ++++- src/ksef2/domain/models/batch.py | 101 +++++-- src/ksef2/domain/models/limits.py | 2 +- src/ksef2/domain/models/session.py | 190 +++++++++++-- src/ksef2/services/async_batch.py | 61 +++-- src/ksef2/services/async_invoices.py | 4 +- src/ksef2/services/batch.py | 59 ++-- src/ksef2/services/invoices.py | 4 +- tests/integration/test_batch_session.py | 15 +- tests/integration/test_invoices_status.py | 4 +- tests/integration/test_session_workflow.py | 36 ++- tests/unit/clients/test_async_auth.py | 16 +- tests/unit/clients/test_async_base.py | 80 ++++++ tests/unit/clients/test_async_batch.py | 39 ++- tests/unit/clients/test_async_online.py | 256 +++++++++++++++++- tests/unit/clients/test_auth.py | 14 + .../unit/clients/test_authenticated_client.py | 215 ++++++++++++++- tests/unit/clients/test_base.py | 74 +++++ tests/unit/clients/test_batch.py | 18 +- tests/unit/clients/test_online.py | 14 +- tests/unit/factories/session.py | 10 +- tests/unit/services/test_async_batch.py | 126 ++++++--- tests/unit/services/test_batch.py | 93 +++++-- tests/unit/test_raw.py | 6 +- tests/unit/test_session_state.py | 256 ++++++++++++++++++ tests/unit/test_shared_refactor_helpers.py | 67 ++++- 38 files changed, 1994 insertions(+), 526 deletions(-) create mode 100644 tests/unit/test_session_state.py diff --git a/scripts/examples/session/session_resume.py b/scripts/examples/session/session_resume.py index 0f77b59..5e21ad0 100644 --- a/scripts/examples/session/session_resume.py +++ b/scripts/examples/session/session_resume.py @@ -13,7 +13,7 @@ from ksef2 import Client, Environment, FormSchema from ksef2.core.tools import generate_nip, generate_pesel -from ksef2.domain.models.session import OnlineSessionState +from ksef2.domain.models.session import OnlineSessionResumeState from ksef2.domain.models.testdata import Identifier, Permission @@ -55,19 +55,19 @@ def run(config: ExampleConfig) -> None: print("Opening session (manual mode)...") session = auth.online_session(form_code=FormSchema.FA3) - state: OnlineSessionState = session.get_state() - state_json = state.model_dump_json() + state: OnlineSessionResumeState = session.resume_state() + state_json = state.to_json() print(f"Session state saved ({len(state_json)} bytes)") print(f" Reference: {state.reference_number}") print(f" Valid until: {state.valid_until}") print("Resuming session from saved state...") - restored_state = OnlineSessionState.model_validate_json(state_json) - resumed_session = auth.resume_online_session(state=restored_state) + restored_state = OnlineSessionResumeState.from_json(state_json) - print("Terminating session...") - resumed_session.close() + print("Terminating resumed session...") + with auth.resume_online_session(state=restored_state): + pass print("Session terminated.") diff --git a/src/ksef2/clients/async_auth.py b/src/ksef2/clients/async_auth.py index 8673260..5b06d05 100644 --- a/src/ksef2/clients/async_auth.py +++ b/src/ksef2/clients/async_auth.py @@ -21,11 +21,12 @@ from ksef2.core.async_protocols import AsyncMiddleware from ksef2.core.crypto import encrypt_token from ksef2.core.polling import async_poll_until -from ksef2.core.stores import CertificateStore +from ksef2.core.stores import CertificateStoreProtocol from ksef2.xades import XAdESPrivateKey, generate_test_certificate from ksef2.domain.models.auth import ( AuthOperationStatus, AuthTokens, + AuthenticationResumeState, ContextIdentifierType, InitTokenAuthenticationRequest, RefreshedToken, @@ -65,7 +66,7 @@ class AsyncAuthClient: def __init__( self, transport: AsyncMiddleware, - certificate_store: CertificateStore, + certificate_store: CertificateStoreProtocol, environment: Environment = Environment.PRODUCTION, ) -> None: self._transport = transport @@ -74,6 +75,10 @@ def __init__( self._certificates = AsyncEncryptionClient(transport) self._auth_ep = AsyncAuthEndpoints(transport) + def resume(self, state: AuthenticationResumeState) -> AsyncAuthenticatedClient: + """Rehydrate an authenticated client from saved authentication state.""" + return self._build_authenticated_client(auth_tokens=state.to_tokens()) + async def with_token( self, *, @@ -331,7 +336,7 @@ def _build_authenticated_client( async def _ensure_certificates(self) -> None: """Populate the certificate store when token authentication needs it.""" - if not self._certificate_store.all(): + if self._certificate_store.needs_refresh("ksef_token_encryption"): self._certificate_store.load(await self._certificates.get_certificates()) async def _poll_until_authenticated( diff --git a/src/ksef2/clients/async_authenticated.py b/src/ksef2/clients/async_authenticated.py index 08c9444..0a16ced 100644 --- a/src/ksef2/clients/async_authenticated.py +++ b/src/ksef2/clients/async_authenticated.py @@ -19,18 +19,19 @@ from ksef2.core import exceptions from ksef2.core.crypto import encrypt_symmetric_key, generate_session_key from ksef2.core.middlewares.async_auth import AsyncBearerTokenMiddleware -from ksef2.core.stores import CertificateStore -from ksef2.domain.models.auth import AuthTokens +from ksef2.core.stores import CertificateStoreProtocol +from ksef2.domain.models.auth import AuthenticationResumeState, AuthTokens from ksef2.domain.models import ( BatchFileInfo, - BatchSessionState, + BatchSessionResumeState, OpenBatchSessionRequest, PreparedBatch, ) from ksef2.domain.models.session import ( FormSchema, - OnlineSessionState, + OnlineSessionResumeState, OpenOnlineSessionRequest, + SessionEncryptionMaterial, ) from ksef2.endpoints.async_session import AsyncSessionEndpoints from ksef2.infra.mappers.sessions import from_spec as session_from_spec @@ -59,7 +60,7 @@ def __init__( self, transport: AsyncMiddleware, auth_tokens: AuthTokens, - certificate_store: CertificateStore, + certificate_store: CertificateStoreProtocol, environment: Environment = Environment.PRODUCTION, ) -> None: self._transport = transport @@ -87,22 +88,32 @@ def refresh_token(self) -> str: """Return the refresh token string paired with the access token.""" return self._auth_tokens.refresh_token.token + def resume_state(self) -> AuthenticationResumeState: + """Return the authentication state needed to rehydrate this branch later.""" + return AuthenticationResumeState.from_tokens(self._auth_tokens) + async def _ensure_encryption_certificates_loaded(self) -> None: - """Load public encryption certificates on first use.""" - if self._certificate_store.all(): - return - self._certificate_store.load(await self._encryption_client.get_certificates()) + """Load public encryption certificates when the cache needs refresh.""" + if self._certificate_store.needs_refresh("symmetric_key_encryption"): + self._certificate_store.load( + await self._encryption_client.get_certificates() + ) async def _get_encryption_material( self, - ) -> tuple[bytes, bytes, bytes, str | None]: + ) -> SessionEncryptionMaterial: """Generate encrypted session material and keep the selected key id.""" await self._ensure_encryption_certificates_loaded() cert = self._certificate_store.get_valid("symmetric_key_encryption") aes_key, iv = generate_session_key() encrypted_key = encrypt_symmetric_key(key=aes_key, cert_b64=cert.certificate) - return aes_key, iv, encrypted_key, cert.public_key_id + return SessionEncryptionMaterial( + aes_key=aes_key, + iv=iv, + encrypted_key=encrypted_key, + public_key_id=cert.public_key_id, + ) async def get_encryption_key(self) -> tuple[bytes, bytes, bytes]: """Generate a session AES key, IV, and encrypted symmetric key payload. @@ -112,63 +123,52 @@ async def get_encryption_key(self) -> tuple[bytes, bytes, bytes]: available. KSeFEncryptionError: If symmetric-key encryption fails. """ - ( - aes_key, - iv, - encrypted_key, - _public_key_id, - ) = await self._get_encryption_material() - return aes_key, iv, encrypted_key - - def online_session( - self, - *, - form_code: FormSchema, - ) -> _AwaitableSession[AsyncOnlineSessionClient]: - """Open a new online invoice session and return a bound session client. - - Raises: - NoCertificateAvailableError: If no valid symmetric-key certificate is - available. - KSeFEncryptionError: If symmetric-key encryption fails. - """ - return _AwaitableSession(self._open_online_session(form_code=form_code)) + material = await self._get_encryption_material() + return material.aes_key, material.iv, material.encrypted_key async def _open_online_session( self, *, form_code: FormSchema, ) -> AsyncOnlineSessionClient: - ( - aes_key, - iv, - encrypted_key, - public_key_id, - ) = await self._get_encryption_material() + material = await self._get_encryption_material() request = OpenOnlineSessionRequest( - encrypted_key=encrypted_key, - iv=iv, - public_key_id=public_key_id, + encrypted_key=material.encrypted_key, + iv=material.iv, + public_key_id=material.public_key_id, form_code=form_code, ) session_data = session_from_spec( await self._session_eps.open_online(session_to_spec(request)) ) - state = OnlineSessionState.from_encoded( + state = OnlineSessionResumeState.from_encoded( reference_number=session_data.reference_number, - aes_key=aes_key, - iv=iv, - access_token=self.access_token, + aes_key=material.aes_key, + iv=material.iv, valid_until=session_data.valid_until, form_code=form_code, ) return AsyncOnlineSessionClient(transport=self._authed_transport, state=state) + def online_session( + self, + *, + form_code: FormSchema, + ) -> _AwaitableSession[AsyncOnlineSessionClient]: + """Open a new online invoice session and return a bound session client. + + Raises: + NoCertificateAvailableError: If no valid symmetric-key certificate is + available. + KSeFEncryptionError: If symmetric-key encryption fails. + """ + return _AwaitableSession(self._open_online_session(form_code=form_code)) + def resume_online_session( self, - state: OnlineSessionState, + state: OnlineSessionResumeState, ) -> AsyncOnlineSessionClient: """Rebind an existing serialized online session state to this client.""" return AsyncOnlineSessionClient(transport=self._authed_transport, state=state) @@ -226,10 +226,12 @@ async def _open_batch_session_from_input( encryption = prepared_batch.encryption return await self._open_batch_session( batch_file=prepared_batch.batch_file, - aes_key=encryption.get_aes_key_bytes(), - iv=encryption.get_iv_bytes(), - encrypted_key=encryption.get_encrypted_key_bytes(), - public_key_id=encryption.public_key_id, + encryption_material=SessionEncryptionMaterial( + aes_key=encryption.get_aes_key_bytes(), + iv=encryption.get_iv_bytes(), + encrypted_key=encryption.get_encrypted_key_bytes(), + public_key_id=encryption.public_key_id, + ), form_code=prepared_batch.form_code, offline_mode=prepared_batch.offline_mode, prepared_batch=prepared_batch, @@ -240,22 +242,50 @@ async def _open_batch_session_from_input( "prepared_batch or batch_file is required when opening a batch session." ) - ( - aes_key, - iv, - encrypted_key, - public_key_id, - ) = await self._get_encryption_material() + material = await self._get_encryption_material() return await self._open_batch_session( batch_file=batch_file, - aes_key=aes_key, - iv=iv, - encrypted_key=encrypted_key, - public_key_id=public_key_id, + encryption_material=material, form_code=form_code, offline_mode=offline_mode, ) + async def _open_batch_session( + self, + *, + batch_file: BatchFileInfo, + encryption_material: SessionEncryptionMaterial, + form_code: FormSchema = FormSchema.FA3, + offline_mode: bool = False, + prepared_batch: PreparedBatch | None = None, + ) -> AsyncBatchSessionClient: + request = OpenBatchSessionRequest( + encrypted_key=encryption_material.encrypted_key, + iv=encryption_material.iv, + public_key_id=encryption_material.public_key_id, + batch_file=batch_file, + form_code=form_code, + offline_mode=offline_mode, + ) + session_response = session_from_spec( + await self._session_eps.open_batch(body=session_to_spec(request)) + ) + + state = BatchSessionResumeState.from_encoded( + reference_number=session_response.reference_number, + aes_key=encryption_material.aes_key, + iv=encryption_material.iv, + form_code=form_code, + part_upload_requests=session_response.part_upload_requests, + ) + return AsyncBatchSessionClient( + transport=self._authed_transport, + state=state, + upload_transport=self._transport, + prepared_batch=prepared_batch, + access_token=self.access_token, + ) + def open_batch_session( self, *, @@ -290,64 +320,28 @@ def open_batch_session( return _AwaitableSession( self._open_batch_session( batch_file=batch_file, - aes_key=aes_key, - iv=iv, - encrypted_key=encrypted_key, - public_key_id=public_key_id, + encryption_material=SessionEncryptionMaterial( + aes_key=aes_key, + iv=iv, + encrypted_key=encrypted_key, + public_key_id=public_key_id, + ), form_code=form_code, offline_mode=offline_mode, prepared_batch=prepared_batch, ) ) - async def _open_batch_session( - self, - *, - batch_file: BatchFileInfo, - aes_key: bytes, - iv: bytes, - encrypted_key: bytes, - public_key_id: str | None = None, - form_code: FormSchema = FormSchema.FA3, - offline_mode: bool = False, - prepared_batch: PreparedBatch | None = None, - ) -> AsyncBatchSessionClient: - request = OpenBatchSessionRequest( - encrypted_key=encrypted_key, - iv=iv, - public_key_id=public_key_id, - batch_file=batch_file, - form_code=form_code, - offline_mode=offline_mode, - ) - session_response = session_from_spec( - await self._session_eps.open_batch(body=session_to_spec(request)) - ) - - state = BatchSessionState.from_encoded( - reference_number=session_response.reference_number, - aes_key=aes_key, - iv=iv, - access_token=self.access_token, - form_code=form_code, - part_upload_requests=session_response.part_upload_requests, - ) - return AsyncBatchSessionClient( - transport=self._authed_transport, - state=state, - upload_transport=self._transport, - prepared_batch=prepared_batch, - ) - def resume_batch_session( self, - state: BatchSessionState, + state: BatchSessionResumeState, ) -> AsyncBatchSessionClient: """Rebind an existing serialized batch session state to this client.""" return AsyncBatchSessionClient( transport=self._authed_transport, state=state, upload_transport=self._transport, + access_token=self.access_token, ) @cached_property diff --git a/src/ksef2/clients/async_base.py b/src/ksef2/clients/async_base.py index a24ef5e..e4ce98e 100644 --- a/src/ksef2/clients/async_base.py +++ b/src/ksef2/clients/async_base.py @@ -1,5 +1,6 @@ """Async root client for authenticated and unauthenticated SDK entry points.""" +import warnings from functools import cached_property from types import TracebackType from typing import Self, final @@ -21,7 +22,7 @@ AsyncClientLifecycleState, ) from ksef2.core.middlewares.async_retry import AsyncRetryMiddleware -from ksef2.domain.models.auth import AuthTokens +from ksef2.domain.models.auth import AuthenticationResumeState, AuthTokens from ksef2.raw.async_facade import AsyncRawClient @@ -44,6 +45,7 @@ def __init__( *, transport_config: TransportConfig | None = None, http_client: httpx.AsyncClient | None = None, + certificate_store: stores.CertificateStoreProtocol | None = None, ) -> None: self._environment = environment self._transport_config = transport_config or TransportConfig() @@ -67,7 +69,11 @@ def __init__( self._transport_config.retry, ) ) - self._certificate_store = stores.CertificateStore() + self._certificate_store = ( + certificate_store + if certificate_store is not None + else stores.CertificateStore() + ) @staticmethod def _build_http_client( @@ -139,13 +145,18 @@ def raw(self) -> AsyncRawClient: return AsyncRawClient(self._transport, self._environment) def authenticated(self, auth_tokens: AuthTokens) -> AsyncAuthenticatedClient: - """Bind caller-supplied auth tokens to an authenticated async SDK client.""" + """Deprecated compatibility wrapper for ``authentication.resume()``.""" self._ensure_open() - return AsyncAuthenticatedClient( - transport=self._transport, - auth_tokens=auth_tokens, - certificate_store=self._certificate_store, - environment=self._environment, + warnings.warn( + "AsyncClient.authenticated(auth_tokens) is deprecated and will be " + "removed in a future release; use " + "client.authentication.resume(AuthenticationResumeState.from_tokens(auth_tokens)) " + "instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.authentication.resume( + AuthenticationResumeState.from_tokens(auth_tokens) ) async def aclose(self) -> None: diff --git a/src/ksef2/clients/async_batch.py b/src/ksef2/clients/async_batch.py index 8757f13..c360e6c 100644 --- a/src/ksef2/clients/async_batch.py +++ b/src/ksef2/clients/async_batch.py @@ -1,11 +1,12 @@ """Async batch session client for managing batch upload sessions.""" +import warnings from types import TracebackType from typing import final from ksef2.core import exceptions from ksef2.core.async_protocols import AsyncMiddleware -from ksef2.domain.models import BatchSessionState +from ksef2.domain.models import BatchSessionResumeState from ksef2.domain.models.batch import PartUploadRequest, PreparedBatch from ksef2.domain.models.session import ( SessionInvoicesResponse, @@ -38,15 +39,17 @@ class AsyncBatchSessionClient: def __init__( self, transport: AsyncMiddleware, - state: BatchSessionState, + state: BatchSessionResumeState, *, upload_transport: AsyncMiddleware | None = None, prepared_batch: PreparedBatch | None = None, + access_token: str | None = None, ) -> None: self._transport = transport self._upload_transport = upload_transport or transport self._state = state self._prepared_batch = prepared_batch + self._access_token = access_token self._invoice_eps = AsyncInvoicesEndpoints(transport) self._session_eps = AsyncSessionEndpoints(transport) self._closed = False @@ -62,9 +65,20 @@ def reference_number(self) -> str: @property def access_token(self) -> str: - """Get the access token for this session.""" + """Deprecated compatibility accessor for the current bearer token.""" self._ensure_open() - return self._state.access_token + warnings.warn( + "BatchSessionClient.access_token is deprecated and will be removed " + "in a future release; persist AuthenticationResumeState separately.", + DeprecationWarning, + stacklevel=2, + ) + if self._access_token is None: + raise exceptions.KSeFValidationError( + "Batch session state does not contain bearer authentication. " + "Use the parent authenticated client's access_token or resume_state()." + ) + return self._access_token @property def aes_key(self) -> bytes: @@ -84,17 +98,20 @@ def part_upload_requests(self) -> list[PartUploadRequest]: self._ensure_open() return self._state.part_upload_requests - def get_state(self) -> BatchSessionState: - """Get the serializable state of this batch session. - - The returned state can be serialized to JSON and used later - to resume the session or access upload URLs. - - Returns: - BatchSessionState containing all session information. - """ + def resume_state(self) -> BatchSessionResumeState: + """Return the sensitive session state needed to resume later.""" return self._state + def get_state(self) -> BatchSessionResumeState: + """Deprecated compatibility wrapper for ``resume_state()``.""" + warnings.warn( + "get_state() is deprecated and will be removed in a future release; " + "use resume_state() instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.resume_state() + async def get_status(self) -> SessionStatusResponse: """Fetch the current processing state of the batch session.""" return session_from_spec( diff --git a/src/ksef2/clients/async_online.py b/src/ksef2/clients/async_online.py index 8ecdca4..aa7186f 100644 --- a/src/ksef2/clients/async_online.py +++ b/src/ksef2/clients/async_online.py @@ -1,6 +1,6 @@ """Async client bound to an open online invoice session.""" -import base64 +import warnings from types import TracebackType from typing import final @@ -13,7 +13,7 @@ from ksef2.domain.models import invoices from ksef2.domain.models.invoices import SendInvoicePayload from ksef2.domain.models.session import ( - OnlineSessionState, + OnlineSessionResumeState, SessionInvoiceStatusResponse, SessionInvoicesResponse, SessionStatusResponse, @@ -44,7 +44,7 @@ class AsyncOnlineSessionClient: httpx.HTTPError: If the HTTP transport fails before KSeF returns a response. """ - def __init__(self, transport: AsyncMiddleware, state: OnlineSessionState): + def __init__(self, transport: AsyncMiddleware, state: OnlineSessionResumeState): self._transport = transport self._state = state self._invoice_eps = AsyncInvoicesEndpoints(transport) @@ -65,8 +65,8 @@ async def send_invoice(self, *, invoice_xml: bytes) -> invoices.SendInvoiceRespo self._ensure_open() encrypted = encrypt_invoice( xml_bytes=invoice_xml, - key=base64.b64decode(self._state.aes_key), - iv=base64.b64decode(self._state.iv), + key=self._state.get_aes_key_bytes(), + iv=self._state.get_iv_bytes(), ) request_body = invoice_to_spec( SendInvoicePayload( @@ -229,10 +229,20 @@ async def aclose(self) -> None: ) self._closed = True - def get_state(self) -> OnlineSessionState: - """Return the serializable session state needed to resume later.""" + def resume_state(self) -> OnlineSessionResumeState: + """Return the sensitive session state needed to resume later.""" return self._state + def get_state(self) -> OnlineSessionResumeState: + """Deprecated compatibility wrapper for ``resume_state()``.""" + warnings.warn( + "get_state() is deprecated and will be removed in a future release; " + "use resume_state() instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.resume_state() + async def __aenter__(self) -> "AsyncOnlineSessionClient": self._ensure_open() return self diff --git a/src/ksef2/clients/auth.py b/src/ksef2/clients/auth.py index 4375f96..78b47b1 100644 --- a/src/ksef2/clients/auth.py +++ b/src/ksef2/clients/auth.py @@ -23,8 +23,9 @@ from ksef2.core.crypto import encrypt_token from ksef2.core.polling import poll_until from ksef2.core.protocols import Middleware -from ksef2.core.stores import CertificateStore +from ksef2.core.stores import CertificateStoreProtocol from ksef2.domain.models.auth import ( + AuthenticationResumeState, AuthOperationStatus, AuthTokens, ContextIdentifierType, @@ -67,7 +68,7 @@ class AuthClient: def __init__( self, transport: Middleware, - certificate_store: CertificateStore, + certificate_store: CertificateStoreProtocol, environment: Environment = Environment.PRODUCTION, ) -> None: self._transport = transport @@ -76,6 +77,10 @@ def __init__( self._certificates = EncryptionClient(transport) self._auth_ep = AuthEndpoints(transport) + def resume(self, state: AuthenticationResumeState) -> AuthenticatedClient: + """Rehydrate an authenticated client from saved authentication state.""" + return self._build_authenticated_client(auth_tokens=state.to_tokens()) + def with_token( self, *, @@ -330,7 +335,7 @@ def _build_authenticated_client( def _ensure_certificates(self) -> None: """Populate the certificate store when token authentication needs it.""" - if not self._certificate_store.all(): + if self._certificate_store.needs_refresh("ksef_token_encryption"): self._certificate_store.load(self._certificates.get_certificates()) def _poll_until_authenticated( diff --git a/src/ksef2/clients/authenticated.py b/src/ksef2/clients/authenticated.py index c9f7878..776dbe9 100644 --- a/src/ksef2/clients/authenticated.py +++ b/src/ksef2/clients/authenticated.py @@ -21,18 +21,19 @@ from ksef2.core.crypto import encrypt_symmetric_key, generate_session_key from ksef2.core.middlewares.auth import BearerTokenMiddleware from ksef2.core.protocols import Middleware -from ksef2.core.stores import CertificateStore +from ksef2.core.stores import CertificateStoreProtocol from ksef2.domain.models import ( BatchFileInfo, - BatchSessionState, + BatchSessionResumeState, OpenBatchSessionRequest, PreparedBatch, ) -from ksef2.domain.models.auth import AuthTokens +from ksef2.domain.models.auth import AuthenticationResumeState, AuthTokens from ksef2.domain.models.session import ( FormSchema, - OnlineSessionState, + OnlineSessionResumeState, OpenOnlineSessionRequest, + SessionEncryptionMaterial, ) from ksef2.endpoints.session import SessionEndpoints from ksef2.infra.mappers.sessions import from_spec as session_from_spec @@ -61,7 +62,7 @@ def __init__( self, transport: Middleware, auth_tokens: AuthTokens, - certificate_store: CertificateStore, + certificate_store: CertificateStoreProtocol, environment: Environment = Environment.PRODUCTION, ) -> None: self._transport = transport @@ -89,22 +90,30 @@ def refresh_token(self) -> str: """Return the refresh token string paired with the access token.""" return self._auth_tokens.refresh_token.token + def resume_state(self) -> AuthenticationResumeState: + """Return the authentication state needed to rehydrate this branch later.""" + return AuthenticationResumeState.from_tokens(self._auth_tokens) + def _ensure_encryption_certificates_loaded(self) -> None: - """Load public encryption certificates on first use.""" - if self._certificate_store.all(): - return - self._certificate_store.load(self._encryption_client.get_certificates()) + """Load public encryption certificates when the cache needs refresh.""" + if self._certificate_store.needs_refresh("symmetric_key_encryption"): + self._certificate_store.load(self._encryption_client.get_certificates()) def _get_encryption_material( self, - ) -> tuple[bytes, bytes, bytes, str | None]: + ) -> SessionEncryptionMaterial: """Generate encrypted session material and keep the selected key id.""" self._ensure_encryption_certificates_loaded() cert = self._certificate_store.get_valid("symmetric_key_encryption") aes_key, iv = generate_session_key() encrypted_key = encrypt_symmetric_key(key=aes_key, cert_b64=cert.certificate) - return aes_key, iv, encrypted_key, cert.public_key_id + return SessionEncryptionMaterial( + aes_key=aes_key, + iv=iv, + encrypted_key=encrypted_key, + public_key_id=cert.public_key_id, + ) def get_encryption_key(self) -> tuple[bytes, bytes, bytes]: """Generate a session AES key, IV, and encrypted symmetric key payload. @@ -114,63 +123,52 @@ def get_encryption_key(self) -> tuple[bytes, bytes, bytes]: available. KSeFEncryptionError: If symmetric-key encryption fails. """ - ( - aes_key, - iv, - encrypted_key, - _public_key_id, - ) = self._get_encryption_material() - return aes_key, iv, encrypted_key - - def online_session( - self, - *, - form_code: FormSchema, - ) -> OnlineSessionClient: - """Open a new online invoice session and return a bound session client. - - Raises: - NoCertificateAvailableError: If no valid symmetric-key certificate is - available. - KSeFEncryptionError: If symmetric-key encryption fails. - """ - return self._open_online_session(form_code=form_code) + material = self._get_encryption_material() + return material.aes_key, material.iv, material.encrypted_key def _open_online_session( self, *, form_code: FormSchema, ) -> OnlineSessionClient: - ( - aes_key, - iv, - encrypted_key, - public_key_id, - ) = self._get_encryption_material() + material = self._get_encryption_material() request = OpenOnlineSessionRequest( - encrypted_key=encrypted_key, - iv=iv, - public_key_id=public_key_id, + encrypted_key=material.encrypted_key, + iv=material.iv, + public_key_id=material.public_key_id, form_code=form_code, ) session_data = session_from_spec( self._session_eps.open_online(session_to_spec(request)) ) - state = OnlineSessionState.from_encoded( + state = OnlineSessionResumeState.from_encoded( reference_number=session_data.reference_number, - aes_key=aes_key, - iv=iv, - access_token=self.access_token, + aes_key=material.aes_key, + iv=material.iv, valid_until=session_data.valid_until, form_code=form_code, ) return OnlineSessionClient(transport=self._authed_transport, state=state) + def online_session( + self, + *, + form_code: FormSchema, + ) -> OnlineSessionClient: + """Open a new online invoice session and return a bound session client. + + Raises: + NoCertificateAvailableError: If no valid symmetric-key certificate is + available. + KSeFEncryptionError: If symmetric-key encryption fails. + """ + return self._open_online_session(form_code=form_code) + def resume_online_session( self, - state: OnlineSessionState, + state: OnlineSessionResumeState, ) -> OnlineSessionClient: """Rebind an existing serialized online session state to this client.""" return OnlineSessionClient(transport=self._authed_transport, state=state) @@ -226,10 +224,12 @@ def _open_batch_session_from_input( encryption = prepared_batch.encryption return self._open_batch_session( batch_file=prepared_batch.batch_file, - aes_key=encryption.get_aes_key_bytes(), - iv=encryption.get_iv_bytes(), - encrypted_key=encryption.get_encrypted_key_bytes(), - public_key_id=encryption.public_key_id, + encryption_material=SessionEncryptionMaterial( + aes_key=encryption.get_aes_key_bytes(), + iv=encryption.get_iv_bytes(), + encrypted_key=encryption.get_encrypted_key_bytes(), + public_key_id=encryption.public_key_id, + ), form_code=prepared_batch.form_code, offline_mode=prepared_batch.offline_mode, prepared_batch=prepared_batch, @@ -240,21 +240,49 @@ def _open_batch_session_from_input( "prepared_batch or batch_file is required when opening a batch session." ) - ( - aes_key, - iv, - encrypted_key, - public_key_id, - ) = self._get_encryption_material() + material = self._get_encryption_material() return self._open_batch_session( batch_file=batch_file, - aes_key=aes_key, - iv=iv, - encrypted_key=encrypted_key, - public_key_id=public_key_id, + encryption_material=material, + form_code=form_code, + offline_mode=offline_mode, + ) + + def _open_batch_session( + self, + *, + batch_file: BatchFileInfo, + encryption_material: SessionEncryptionMaterial, + form_code: FormSchema = FormSchema.FA3, + offline_mode: bool = False, + prepared_batch: PreparedBatch | None = None, + ) -> BatchSessionClient: + request = OpenBatchSessionRequest( + encrypted_key=encryption_material.encrypted_key, + iv=encryption_material.iv, + public_key_id=encryption_material.public_key_id, + batch_file=batch_file, form_code=form_code, offline_mode=offline_mode, ) + session_response = session_from_spec( + self._session_eps.open_batch(body=session_to_spec(request)) + ) + + state = BatchSessionResumeState.from_encoded( + reference_number=session_response.reference_number, + aes_key=encryption_material.aes_key, + iv=encryption_material.iv, + form_code=form_code, + part_upload_requests=session_response.part_upload_requests, + ) + return BatchSessionClient( + transport=self._authed_transport, + state=state, + upload_transport=self._transport, + prepared_batch=prepared_batch, + access_token=self.access_token, + ) def open_batch_session( self, @@ -289,63 +317,27 @@ def open_batch_session( """ return self._open_batch_session( batch_file=batch_file, - aes_key=aes_key, - iv=iv, - encrypted_key=encrypted_key, - public_key_id=public_key_id, - form_code=form_code, - offline_mode=offline_mode, - prepared_batch=prepared_batch, - ) - - def _open_batch_session( - self, - *, - batch_file: BatchFileInfo, - aes_key: bytes, - iv: bytes, - encrypted_key: bytes, - public_key_id: str | None = None, - form_code: FormSchema = FormSchema.FA3, - offline_mode: bool = False, - prepared_batch: PreparedBatch | None = None, - ) -> BatchSessionClient: - request = OpenBatchSessionRequest( - encrypted_key=encrypted_key, - iv=iv, - public_key_id=public_key_id, - batch_file=batch_file, + encryption_material=SessionEncryptionMaterial( + aes_key=aes_key, + iv=iv, + encrypted_key=encrypted_key, + public_key_id=public_key_id, + ), form_code=form_code, offline_mode=offline_mode, - ) - session_response = session_from_spec( - self._session_eps.open_batch(body=session_to_spec(request)) - ) - - state = BatchSessionState.from_encoded( - reference_number=session_response.reference_number, - aes_key=aes_key, - iv=iv, - access_token=self.access_token, - form_code=form_code, - part_upload_requests=session_response.part_upload_requests, - ) - return BatchSessionClient( - transport=self._authed_transport, - state=state, - upload_transport=self._transport, prepared_batch=prepared_batch, ) def resume_batch_session( self, - state: BatchSessionState, + state: BatchSessionResumeState, ) -> BatchSessionClient: """Rebind an existing serialized batch session state to this client.""" return BatchSessionClient( transport=self._authed_transport, state=state, upload_transport=self._transport, + access_token=self.access_token, ) @cached_property diff --git a/src/ksef2/clients/base.py b/src/ksef2/clients/base.py index f650fb5..868f618 100644 --- a/src/ksef2/clients/base.py +++ b/src/ksef2/clients/base.py @@ -1,5 +1,6 @@ """Public root client for authenticated and unauthenticated SDK entry points.""" +import warnings from functools import cached_property from types import TracebackType from typing import final, Self @@ -14,7 +15,7 @@ from ksef2.core import exceptions, middlewares, stores from ksef2.core.http_config import build_http_client_kwargs from ksef2.core.http import HttpTransport -from ksef2.domain.models.auth import AuthTokens +from ksef2.domain.models.auth import AuthenticationResumeState, AuthTokens from ksef2.raw.facade import RawClient @@ -37,6 +38,7 @@ def __init__( *, transport_config: TransportConfig | None = None, http_client: httpx.Client | None = None, + certificate_store: stores.CertificateStoreProtocol | None = None, ) -> None: self._environment = environment self._transport_config = transport_config or TransportConfig() @@ -55,7 +57,11 @@ def __init__( self._transport_config.retry, ) ) - self._certificate_store = stores.CertificateStore() + self._certificate_store = ( + certificate_store + if certificate_store is not None + else stores.CertificateStore() + ) @staticmethod def _build_http_client( @@ -129,13 +135,18 @@ def raw(self) -> RawClient: return RawClient(self._transport, self._environment) def authenticated(self, auth_tokens: AuthTokens) -> AuthenticatedClient: - """Bind caller-supplied auth tokens to an authenticated SDK client.""" + """Deprecated compatibility wrapper for ``authentication.resume()``.""" self._ensure_open() - return AuthenticatedClient( - transport=self._transport, - auth_tokens=auth_tokens, - certificate_store=self._certificate_store, - environment=self._environment, + warnings.warn( + "Client.authenticated(auth_tokens) is deprecated and will be removed " + "in a future release; use " + "client.authentication.resume(AuthenticationResumeState.from_tokens(auth_tokens)) " + "instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.authentication.resume( + AuthenticationResumeState.from_tokens(auth_tokens) ) def close(self) -> None: diff --git a/src/ksef2/clients/batch.py b/src/ksef2/clients/batch.py index 540a508..59a3c43 100644 --- a/src/ksef2/clients/batch.py +++ b/src/ksef2/clients/batch.py @@ -3,12 +3,13 @@ """Batch session client for managing batch upload sessions.""" +import warnings from types import TracebackType from typing import final from ksef2.core import exceptions from ksef2.core.protocols import Middleware -from ksef2.domain.models import BatchSessionState +from ksef2.domain.models import BatchSessionResumeState from ksef2.domain.models.batch import PartUploadRequest, PreparedBatch from ksef2.domain.models.session import ( SessionInvoicesResponse, @@ -41,15 +42,17 @@ class BatchSessionClient: def __init__( self, transport: Middleware, - state: BatchSessionState, + state: BatchSessionResumeState, *, upload_transport: Middleware | None = None, prepared_batch: PreparedBatch | None = None, + access_token: str | None = None, ) -> None: self._transport = transport self._upload_transport = upload_transport or transport self._state = state self._prepared_batch = prepared_batch + self._access_token = access_token self._invoice_eps = InvoicesEndpoints(transport) self._session_eps = SessionEndpoints(transport) self._closed = False @@ -65,9 +68,20 @@ def reference_number(self) -> str: @property def access_token(self) -> str: - """Get the access token for this session.""" + """Deprecated compatibility accessor for the current bearer token.""" self._ensure_open() - return self._state.access_token + warnings.warn( + "BatchSessionClient.access_token is deprecated and will be removed " + "in a future release; persist AuthenticationResumeState separately.", + DeprecationWarning, + stacklevel=2, + ) + if self._access_token is None: + raise exceptions.KSeFValidationError( + "Batch session state does not contain bearer authentication. " + "Use the parent authenticated client's access_token or resume_state()." + ) + return self._access_token @property def aes_key(self) -> bytes: @@ -87,17 +101,20 @@ def part_upload_requests(self) -> list[PartUploadRequest]: self._ensure_open() return self._state.part_upload_requests - def get_state(self) -> BatchSessionState: - """Get the serializable state of this batch session. - - The returned state can be serialized to JSON and used later - to resume the session or access upload URLs. - - Returns: - BatchSessionState containing all session information. - """ + def resume_state(self) -> BatchSessionResumeState: + """Return the sensitive session state needed to resume later.""" return self._state + def get_state(self) -> BatchSessionResumeState: + """Deprecated compatibility wrapper for ``resume_state()``.""" + warnings.warn( + "get_state() is deprecated and will be removed in a future release; " + "use resume_state() instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.resume_state() + def get_status(self) -> SessionStatusResponse: """Fetch the current processing state of the batch session.""" return session_from_spec( diff --git a/src/ksef2/clients/online.py b/src/ksef2/clients/online.py index ab782b5..d632015 100644 --- a/src/ksef2/clients/online.py +++ b/src/ksef2/clients/online.py @@ -3,7 +3,7 @@ """Client bound to an open online invoice session.""" -import base64 +import warnings from types import TracebackType from typing import final @@ -16,7 +16,7 @@ from ksef2.domain.models import invoices from ksef2.domain.models.invoices import SendInvoicePayload from ksef2.domain.models.session import ( - OnlineSessionState, + OnlineSessionResumeState, SessionInvoicesResponse, SessionInvoiceStatusResponse, SessionStatusResponse, @@ -47,7 +47,7 @@ class OnlineSessionClient: httpx.HTTPError: If the HTTP transport fails before KSeF returns a response. """ - def __init__(self, transport: Middleware, state: OnlineSessionState): + def __init__(self, transport: Middleware, state: OnlineSessionResumeState): self._transport = transport self._state = state self._invoice_eps = InvoicesEndpoints(transport) @@ -68,8 +68,8 @@ def send_invoice(self, *, invoice_xml: bytes) -> invoices.SendInvoiceResponse: self._ensure_open() encrypted = encrypt_invoice( xml_bytes=invoice_xml, - key=base64.b64decode(self._state.aes_key), - iv=base64.b64decode(self._state.iv), + key=self._state.get_aes_key_bytes(), + iv=self._state.get_iv_bytes(), ) request_body = invoice_to_spec( SendInvoicePayload( @@ -232,10 +232,20 @@ def close(self) -> None: ) self._closed = True - def get_state(self) -> OnlineSessionState: - """Return the serializable session state needed to resume later.""" + def resume_state(self) -> OnlineSessionResumeState: + """Return the sensitive session state needed to resume later.""" return self._state + def get_state(self) -> OnlineSessionResumeState: + """Deprecated compatibility wrapper for ``resume_state()``.""" + warnings.warn( + "get_state() is deprecated and will be removed in a future release; " + "use resume_state() instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.resume_state() + def __enter__(self) -> "OnlineSessionClient": self._ensure_open() return self diff --git a/src/ksef2/domain/models/__init__.py b/src/ksef2/domain/models/__init__.py index de146d8..23390ab 100644 --- a/src/ksef2/domain/models/__init__.py +++ b/src/ksef2/domain/models/__init__.py @@ -1,15 +1,19 @@ """Public domain models and typed request/response helpers.""" +import warnings +from typing import TYPE_CHECKING + from ksef2.domain.models.base import KSeFBaseModel, KSeFBaseParams from ksef2.domain.models.compression import CompressionType, CompressionTypeEnum from ksef2.domain.models.session import ( - BaseSessionState, + BaseSessionResumeState, FormSchema, InvoiceStatusInfo, ListSessionsResponse, OpenOnlineSessionRequest, OpenOnlineSessionResponse, - OnlineSessionState, + OnlineSessionResumeState, + SessionEncryptionMaterial, SessionInvoiceStatusResponse, SessionInvoicesResponse, SessionStatusResponse, @@ -25,7 +29,7 @@ BatchInvoiceHash, BatchPreparedPart, OpenBatchSessionRequest, - BatchSessionState, + BatchSessionResumeState, OpenBatchSessionResponse, PartUploadRequest, PreparedBatch, @@ -152,6 +156,7 @@ from ksef2.domain.models.auth import ( AuthenticationMethodCategory, AuthenticationMethod, + AuthenticationResumeState, AuthenticationSession, AuthenticationSessionsResponse, AuthInitResponse, @@ -170,12 +175,51 @@ CertificateStatusValue, CertificateTypeValue, ) +from ksef2.domain.models.limits import ( + ApiRateLimits, + ContextLimits, + SessionLimits, + SubjectLimits, +) from ksef2.domain.models.peppol import ( PeppolProvider, ListPeppolProvidersResponse, ) from ksef2.domain.models.pagination import InvoiceMetadataParams +if TYPE_CHECKING: + BaseSessionState = BaseSessionResumeState + OnlineSessionState = OnlineSessionResumeState + BatchSessionState = BatchSessionResumeState + + +_DEPRECATED_EXPORTS = { + "BaseSessionState": ( + BaseSessionResumeState, + "ksef2.domain.models.BaseSessionState is deprecated and will be " + "removed in a future release; use BaseSessionResumeState instead.", + ), + "OnlineSessionState": ( + OnlineSessionResumeState, + "ksef2.domain.models.OnlineSessionState is deprecated and will be " + "removed in a future release; use OnlineSessionResumeState instead.", + ), + "BatchSessionState": ( + BatchSessionResumeState, + "ksef2.domain.models.BatchSessionState is deprecated and will be " + "removed in a future release; use BatchSessionResumeState instead.", + ), +} + + +def __getattr__(name: str) -> object: + if name in _DEPRECATED_EXPORTS: + value, message = _DEPRECATED_EXPORTS[name] + warnings.warn(message, DeprecationWarning, stacklevel=2) + return value + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + __all__ = [ # base "KSeFBaseModel", @@ -183,13 +227,14 @@ "CompressionType", "CompressionTypeEnum", # session - "BaseSessionState", + "BaseSessionResumeState", "FormSchema", "InvoiceStatusInfo", "ListSessionsResponse", "OpenOnlineSessionRequest", "OpenOnlineSessionResponse", - "OnlineSessionState", + "OnlineSessionResumeState", + "SessionEncryptionMaterial", "SessionInvoiceStatusResponse", "SessionInvoicesResponse", "SessionStatusResponse", @@ -204,7 +249,7 @@ "BatchPreparedPart", "BatchEncryptionData", "OpenBatchSessionRequest", - "BatchSessionState", + "BatchSessionResumeState", "OpenBatchSessionResponse", "PartUploadRequest", "PreparedBatch", @@ -328,6 +373,7 @@ # auth "AuthenticationMethodCategory", "AuthenticationMethod", + "AuthenticationResumeState", "AuthenticationSession", "AuthenticationSessionsResponse", "AuthInitResponse", @@ -344,6 +390,11 @@ "CertificateLimitsResponse", "CertificateStatusValue", "CertificateTypeValue", + # limits + "ApiRateLimits", + "ContextLimits", + "SessionLimits", + "SubjectLimits", # peppol "PeppolProvider", "ListPeppolProvidersResponse", diff --git a/src/ksef2/domain/models/auth.py b/src/ksef2/domain/models/auth.py index 18a91c2..815fb59 100644 --- a/src/ksef2/domain/models/auth.py +++ b/src/ksef2/domain/models/auth.py @@ -1,8 +1,12 @@ """Domain models for authentication flows and auth sessions.""" +import json +from collections.abc import Mapping from datetime import datetime from enum import StrEnum -from typing import Literal +from typing import Literal, Self + +from pydantic import SecretStr from ksef2.domain.models.base import KSeFBaseModel @@ -140,6 +144,71 @@ class AuthTokens(KSeFBaseModel): refresh_token: TokenCredentials +class AuthenticationResumeState(KSeFBaseModel): + """Serializable authentication state used to rehydrate an authenticated client. + + Use ``to_json()`` when intentionally exporting resumable JSON containing + bearer credentials. Normal Pydantic dumps and repr output keep token values + redacted. + """ + + access_token: SecretStr + access_token_valid_until: datetime + refresh_token: SecretStr + refresh_token_valid_until: datetime + + @classmethod + def from_tokens(cls, auth_tokens: AuthTokens) -> Self: + """Create resume state from authenticated token credentials.""" + return cls( + access_token=SecretStr(auth_tokens.access_token.token), + access_token_valid_until=auth_tokens.access_token.valid_until, + refresh_token=SecretStr(auth_tokens.refresh_token.token), + refresh_token_valid_until=auth_tokens.refresh_token.valid_until, + ) + + def to_tokens(self) -> AuthTokens: + """Return the token model needed to bind authenticated SDK clients.""" + return AuthTokens( + access_token=TokenCredentials( + token=self.access_token.get_secret_value(), + valid_until=self.access_token_valid_until, + ), + refresh_token=TokenCredentials( + token=self.refresh_token.get_secret_value(), + valid_until=self.refresh_token_valid_until, + ), + ) + + def to_dict( + self, + *, + mode: Literal["json", "python"] | str = "json", + ) -> dict[str, object]: + """Export authentication state with bearer credentials included.""" + data: dict[str, object] = self.model_dump(mode=mode) + data["access_token"] = self.access_token.get_secret_value() + data["refresh_token"] = self.refresh_token.get_secret_value() + return data + + def to_json(self, *, indent: int | None = None) -> str: + """Export authentication state as JSON with bearer credentials included.""" + data = self.to_dict(mode="json") + if indent is None: + return json.dumps(data, separators=(",", ":")) + return json.dumps(data, indent=indent) + + @classmethod + def from_dict(cls, state: Mapping[str, object]) -> Self: + """Restore authentication state from a dictionary exported by ``to_dict()``.""" + return cls.model_validate(state) + + @classmethod + def from_json(cls, state: str | bytes | bytearray) -> Self: + """Restore authentication state from JSON exported by ``to_json()``.""" + return cls.model_validate_json(state) + + class RefreshedToken(KSeFBaseModel): """Access token returned by the refresh endpoint.""" diff --git a/src/ksef2/domain/models/batch.py b/src/ksef2/domain/models/batch.py index 7b820e4..d9ffe7a 100644 --- a/src/ksef2/domain/models/batch.py +++ b/src/ksef2/domain/models/batch.py @@ -1,18 +1,27 @@ """Domain models for batch session operations.""" -from __future__ import annotations - import base64 -from typing import Self +import warnings +from typing import TYPE_CHECKING, Literal, Self, override -from pydantic import field_validator +from pydantic import Field, SecretStr, field_validator from ksef2.domain.models.base import KSeFBaseModel from ksef2.domain.models.compression import ( CompressionType, normalize_compression_type, ) -from ksef2.domain.models.session import BaseSessionState, FormSchema +from ksef2.domain.models.session import BaseSessionResumeState, FormSchema + + +MAX_BATCH_FILE_SIZE_BYTES = 5_000_000_000 +MAX_BATCH_FILE_PARTS = 50 +MAX_BATCH_PART_PRE_ENCRYPTION_SIZE_BYTES = 100_000_000 +MAX_AES_CBC_PADDING_BYTES = 16 +# The API's 100MB part limit is pre-encryption; this model stores encrypted bytes. +MAX_BATCH_ENCRYPTED_PART_SIZE_BYTES = ( + MAX_BATCH_PART_PRE_ENCRYPTION_SIZE_BYTES + MAX_AES_CBC_PADDING_BYTES +) class BatchInvoice(KSeFBaseModel): @@ -32,10 +41,10 @@ class BatchInvoiceHash(KSeFBaseModel): class BatchFilePart(KSeFBaseModel): """Information about a part of the batch file.""" - ordinal_number: int + ordinal_number: int = Field(ge=1) """Sequential number of the file part (1-indexed).""" - file_size: int + file_size: int = Field(ge=0, le=MAX_BATCH_ENCRYPTED_PART_SIZE_BYTES) """Size of the encrypted file part in bytes.""" file_hash: str @@ -45,7 +54,7 @@ class BatchFilePart(KSeFBaseModel): class BatchFileInfo(KSeFBaseModel): """Information about the batch file being uploaded.""" - file_size: int + file_size: int = Field(ge=0, le=MAX_BATCH_FILE_SIZE_BYTES) """Total size of the batch file in bytes. Max 5GB.""" file_hash: str @@ -54,7 +63,7 @@ class BatchFileInfo(KSeFBaseModel): compression_type: CompressionType | None = None """Compression used for the batch file. Defaults to KSeF's ZIP behavior.""" - parts: list[BatchFilePart] + parts: list[BatchFilePart] = Field(max_length=MAX_BATCH_FILE_PARTS) """List of file parts. Max 50 parts, each max 100MB before encryption.""" @field_validator("compression_type", mode="before") @@ -162,48 +171,96 @@ class OpenBatchSessionResponse(KSeFBaseModel): """Upload instructions for each file part.""" -class BatchSessionState(BaseSessionState): - """Serializable state of a batch session. +class BatchSessionResumeState(BaseSessionResumeState): + """Serializable resume state of a batch session. This class holds all information needed to resume a batch session - or to upload file parts. Can be serialized to JSON for persistence. + or to upload file parts. Use ``to_json()`` when + intentionally exporting resumable JSON containing encryption material and + presigned upload URLs. - Inherits common session fields from BaseSessionState: - - reference_number, aes_key, iv, access_token, form_code + Inherits common session fields from BaseSessionResumeState: + - reference_number, aes_key, iv, form_code - get_aes_key_bytes(), get_iv_bytes() helper methods """ - part_upload_requests: list[PartUploadRequest] + part_upload_requests: list[PartUploadRequest] = Field(exclude=True, repr=False) """Upload instructions for each file part.""" + @override + def to_dict( + self, + *, + mode: Literal["json", "python"] | str = "json", + ) -> dict[str, object]: + """Export batch resume state with secrets and upload URLs included. + + The returned data contains the AES key, IV, and presigned upload URLs. + Store and log it only as protected credential material. + """ + data = super().to_dict(mode=mode) + data["part_upload_requests"] = [ + request.model_dump(mode=mode) for request in self.part_upload_requests + ] + return data + @classmethod def from_encoded( cls, reference_number: str, aes_key: bytes, iv: bytes, - access_token: str, form_code: FormSchema, part_upload_requests: list[PartUploadRequest], - ) -> BatchSessionState: + *, + access_token: str | None = None, + ) -> Self: """Create state from raw bytes (aes_key, iv). Args: reference_number: Batch session reference number. aes_key: Raw AES key bytes. iv: Raw initialization vector bytes. - access_token: Bearer token for authentication. form_code: Invoice schema for this session. part_upload_requests: Upload instructions for file parts. + access_token: Deprecated and ignored. Persist authentication state + separately with ``AuthenticationResumeState``. Returns: - BatchSessionState with Base64-encoded key and IV. + BatchSessionResumeState with Base64-encoded key and IV. """ + if access_token is not None: + warnings.warn( + "BatchSessionResumeState.from_encoded(access_token=...) is " + "deprecated and ignored; persist AuthenticationResumeState separately.", + DeprecationWarning, + stacklevel=2, + ) return cls( reference_number=reference_number, - aes_key=base64.b64encode(aes_key).decode(), - iv=base64.b64encode(iv).decode(), - access_token=access_token, + aes_key=SecretStr(base64.b64encode(aes_key).decode()), + iv=SecretStr(base64.b64encode(iv).decode()), form_code=form_code, part_upload_requests=part_upload_requests, ) + + +if TYPE_CHECKING: + BatchSessionState = BatchSessionResumeState + + +_DEPRECATED_BATCH_EXPORTS = { + "BatchSessionState": ( + BatchSessionResumeState, + "BatchSessionState is deprecated and will be removed in a future release; " + "use BatchSessionResumeState instead.", + ), +} + + +def __getattr__(name: str) -> object: + if name in _DEPRECATED_BATCH_EXPORTS: + value, message = _DEPRECATED_BATCH_EXPORTS[name] + warnings.warn(message, DeprecationWarning, stacklevel=2) + return value + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/ksef2/domain/models/limits.py b/src/ksef2/domain/models/limits.py index 498837c..4a0f7f4 100644 --- a/src/ksef2/domain/models/limits.py +++ b/src/ksef2/domain/models/limits.py @@ -1,6 +1,6 @@ """Domain models for effective and overrideable KSeF limits.""" -from ksef2.domain.models import KSeFBaseModel +from ksef2.domain.models.base import KSeFBaseModel class SessionLimits(KSeFBaseModel): diff --git a/src/ksef2/domain/models/session.py b/src/ksef2/domain/models/session.py index f9d2164..4213800 100644 --- a/src/ksef2/domain/models/session.py +++ b/src/ksef2/domain/models/session.py @@ -1,11 +1,20 @@ """Domain models for online, batch, and authentication sessions.""" import base64 +import json +import warnings +from collections.abc import Mapping from datetime import datetime from enum import Enum, StrEnum -from typing import Self, Literal +from typing import TYPE_CHECKING, Literal, Self, cast -from pydantic import AwareDatetime, AnyUrl, field_validator +from pydantic import ( + AwareDatetime, + AnyUrl, + SecretStr, + field_validator, + model_validator, +) from ksef2.domain.models.base import KSeFBaseModel @@ -25,6 +34,15 @@ def __init__(self, system_code: str, schema_version: str, schema_value: str): self.schema_value = schema_value +class SessionEncryptionMaterial(KSeFBaseModel): + """Raw and encrypted symmetric session key material.""" + + aes_key: bytes + iv: bytes + encrypted_key: bytes + public_key_id: str | None = None + + type SessionType = Literal["online", "batch"] @@ -81,7 +99,7 @@ def normalize_session_type(value: SessionType | SessionTypeEnum | str) -> Sessio lowered_value = value.strip().lower() if lowered_value in _SESSION_TYPE_TO_SPEC: - return lowered_value # pyright: ignore[reportReturnType] + return lowered_value if value in _SESSION_TYPE_FROM_SPEC: return _SESSION_TYPE_FROM_SPEC[value] @@ -105,7 +123,7 @@ def normalize_session_status( lowered_value = value.strip().lower() if lowered_value in _SESSION_STATUS_TO_SPEC: - return lowered_value # pyright: ignore[reportReturnType] + return lowered_value if value in _SESSION_STATUS_FROM_SPEC: return _SESSION_STATUS_FROM_SPEC[value] @@ -235,8 +253,17 @@ class ListSessionsResponse(KSeFBaseModel): sessions: list[SessionSummary] -class BaseSessionState(KSeFBaseModel): - """Base class for session state with common fields. +def _warn_deprecated(old_name: str, new_name: str) -> None: + warnings.warn( + f"{old_name} is deprecated and will be removed in a future release; " + f"use {new_name} instead.", + DeprecationWarning, + stacklevel=3, + ) + + +class BaseSessionResumeState(KSeFBaseModel): + """Base class for session resume state with common fields. This class contains fields shared between online and batch sessions. It provides serialization/deserialization support and helper methods @@ -246,18 +273,35 @@ class BaseSessionState(KSeFBaseModel): reference_number: str """Reference number of the session.""" - aes_key: str + aes_key: SecretStr """AES key for encrypting data, Base64 encoded.""" - iv: str + iv: SecretStr """Initialization vector for AES encryption, Base64 encoded.""" - access_token: str - """Bearer token for API authentication.""" - form_code: FormSchema """Invoice schema used for this session.""" + @model_validator(mode="before") + @classmethod + def _drop_legacy_access_token(cls, data: object) -> object: + """Accept pre-auth-state resume JSON that still carried bearer auth.""" + if not isinstance(data, dict): + return data + state_data = cast(dict[str, object], data) + if "access_token" not in state_data: + return state_data + + warnings.warn( + "Session resume state access_token is deprecated and ignored; " + "persist AuthenticationResumeState separately.", + DeprecationWarning, + stacklevel=3, + ) + cleaned = dict(state_data) + _ = cleaned.pop("access_token") + return cleaned + @field_validator("form_code", mode="before") @classmethod def _coerce_form_code(cls, value: object) -> object: @@ -267,7 +311,7 @@ def _coerce_form_code(cls, value: object) -> object: Also accept enum names as a convenience ("FA3", etc.). """ if isinstance(value, list): - return tuple(value) + return tuple(cast(list[object], value)) if isinstance(value, str): try: return FormSchema[value] @@ -277,18 +321,83 @@ def _coerce_form_code(cls, value: object) -> object: def get_aes_key_bytes(self) -> bytes: """Get the AES key as raw bytes.""" - return base64.b64decode(self.aes_key) + return base64.b64decode(self.aes_key.get_secret_value()) def get_iv_bytes(self) -> bytes: """Get the initialization vector as raw bytes.""" - return base64.b64decode(self.iv) + return base64.b64decode(self.iv.get_secret_value()) + + def to_dict( + self, + *, + mode: Literal["json", "python"] | str = "json", + ) -> dict[str, object]: + """Export resume state with credentials included. + + The returned data contains the AES key, IV, and for batch sessions the + presigned upload URLs. Store and log it only as protected credential + material. + """ + data: dict[str, object] = self.model_dump(mode=mode) + data["aes_key"] = self.aes_key.get_secret_value() + data["iv"] = self.iv.get_secret_value() + if mode == "json": + data["form_code"] = self.form_code.name + return data + + def to_json(self, *, indent: int | None = None) -> str: + """Export resume state as JSON with credentials included.""" + data = self.to_dict(mode="json") + if indent is None: + return json.dumps(data, separators=(",", ":")) + return json.dumps(data, indent=indent) + @classmethod + def from_dict(cls, state: Mapping[str, object]) -> Self: + """Restore resume state from a dictionary exported by ``to_dict()``.""" + return cls.model_validate(state) + + @classmethod + def from_json(cls, state: str | bytes | bytearray) -> Self: + """Restore resume state from JSON exported by ``to_json()``.""" + return cls.model_validate_json(state) + + def dump_state( + self, + *, + mode: Literal["json", "python"] | str = "python", + ) -> dict[str, object]: + """Deprecated compatibility wrapper for ``to_dict()``.""" + _warn_deprecated("dump_state()", "to_dict()") + return self.to_dict(mode=mode) + + def model_dump_sensitive( + self, + *, + mode: Literal["json", "python"] | str = "python", + ) -> dict[str, object]: + """Deprecated compatibility wrapper for ``to_dict()``.""" + _warn_deprecated("model_dump_sensitive()", "to_dict()") + return self.to_dict(mode=mode) + + def model_dump_sensitive_json(self, *, indent: int | None = None) -> str: + """Deprecated compatibility wrapper for ``to_json()``.""" + _warn_deprecated("model_dump_sensitive_json()", "to_json()") + return self.to_json(indent=indent) -class OnlineSessionState(BaseSessionState): - """Serializable state of an online session. + @classmethod + def from_state(cls, state: Mapping[str, object]) -> Self: + """Deprecated compatibility wrapper for ``from_dict()``.""" + _warn_deprecated("from_state()", "from_dict()") + return cls.from_dict(state) + + +class OnlineSessionResumeState(BaseSessionResumeState): + """Serializable resume state of an online session. This class holds all information needed to resume an online session. - Can be serialized to JSON for persistence. + Use ``to_json()`` when intentionally exporting + resumable JSON containing credentials. """ valid_until: AwareDatetime @@ -300,9 +409,10 @@ def from_encoded( reference_number: str, aes_key: bytes, iv: bytes, - access_token: str, valid_until: datetime, form_code: FormSchema, + *, + access_token: str | None = None, ) -> Self: """Create state from raw bytes (aes_key, iv). @@ -310,18 +420,52 @@ def from_encoded( reference_number: Session reference number. aes_key: Raw AES key bytes. iv: Raw initialization vector bytes. - access_token: Bearer token for authentication. valid_until: Session expiration time. form_code: Invoice schema for this session. + access_token: Deprecated and ignored. Persist authentication state + separately with ``AuthenticationResumeState``. Returns: - SessionState with Base64-encoded key and IV. + OnlineSessionResumeState with Base64-encoded key and IV. """ + if access_token is not None: + warnings.warn( + "OnlineSessionResumeState.from_encoded(access_token=...) is " + "deprecated and ignored; persist AuthenticationResumeState separately.", + DeprecationWarning, + stacklevel=2, + ) return cls( reference_number=reference_number, - aes_key=base64.b64encode(aes_key).decode(), - iv=base64.b64encode(iv).decode(), - access_token=access_token, + aes_key=SecretStr(base64.b64encode(aes_key).decode()), + iv=SecretStr(base64.b64encode(iv).decode()), valid_until=valid_until, form_code=form_code, ) + + +if TYPE_CHECKING: + BaseSessionState = BaseSessionResumeState + OnlineSessionState = OnlineSessionResumeState + + +_DEPRECATED_SESSION_EXPORTS = { + "BaseSessionState": ( + BaseSessionResumeState, + "BaseSessionState is deprecated and will be removed in a future release; " + "use BaseSessionResumeState instead.", + ), + "OnlineSessionState": ( + OnlineSessionResumeState, + "OnlineSessionState is deprecated and will be removed in a future release; " + "use OnlineSessionResumeState instead.", + ), +} + + +def __getattr__(name: str) -> object: + if name in _DEPRECATED_SESSION_EXPORTS: + value, message = _DEPRECATED_SESSION_EXPORTS[name] + warnings.warn(message, DeprecationWarning, stacklevel=2) + return value + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/ksef2/services/async_batch.py b/src/ksef2/services/async_batch.py index e913d5a..211100c 100644 --- a/src/ksef2/services/async_batch.py +++ b/src/ksef2/services/async_batch.py @@ -3,16 +3,22 @@ import asyncio from collections.abc import Awaitable, Callable, Iterable from pathlib import Path -from typing import final +from typing import Protocol, final from ksef2.clients._async_session import _AwaitableSession from ksef2.clients.async_batch import AsyncBatchSessionClient from ksef2.core import exceptions from ksef2.core.async_protocols import AsyncMiddleware from ksef2.core.polling import async_poll_until -from ksef2.domain.models.batch import BatchInvoice, BatchSessionState, PreparedBatch +from ksef2.domain.models.batch import ( + BatchFileInfo, + BatchInvoice, + BatchSessionResumeState, + PreparedBatch, +) from ksef2.domain.models.session import ( FormSchema, + SessionEncryptionMaterial, SessionInvoicesResponse, SessionStatusResponse, ) @@ -26,6 +32,21 @@ ) +class AsyncBatchSessionOpener(Protocol): + def __call__( + self, + *, + batch_file: BatchFileInfo, + aes_key: bytes, + iv: bytes, + encrypted_key: bytes, + public_key_id: str | None = None, + form_code: FormSchema = FormSchema.FA3, + offline_mode: bool = False, + prepared_batch: PreparedBatch | None = None, + ) -> Awaitable[AsyncBatchSessionClient]: ... + + @final class AsyncBatchService: """Async high-level workflow for preparing and sending invoice batches. @@ -46,10 +67,8 @@ def __init__( *, authed_transport: AsyncMiddleware, upload_transport: AsyncMiddleware, - get_encryption_key: Callable[ - [], Awaitable[tuple[bytes, bytes, bytes, str | None]] - ], - open_batch_session: Callable[..., Awaitable[AsyncBatchSessionClient]], + get_encryption_key: Callable[[], Awaitable[SessionEncryptionMaterial]], + open_batch_session: AsyncBatchSessionOpener, ) -> None: self._invoice_eps = AsyncInvoicesEndpoints(authed_transport) self._session_eps = AsyncSessionEndpoints(authed_transport) @@ -83,14 +102,14 @@ async def prepare_batch( KSeFEncryptionError: If key or part encryption fails. KSeFValidationError: If the invoice list or part size is invalid. """ - aes_key, iv, encrypted_key, public_key_id = await self._get_encryption_key() + material = await self._get_encryption_key() return await asyncio.to_thread( prepare_batch_package, invoices=invoices, - aes_key=aes_key, - iv=iv, - encrypted_key=encrypted_key, - public_key_id=public_key_id, + aes_key=material.aes_key, + iv=material.iv, + encrypted_key=material.encrypted_key, + public_key_id=material.public_key_id, form_code=form_code, offline_mode=offline_mode, max_part_size=max_part_size, @@ -216,7 +235,7 @@ async def submit_prepared_batch( self, *, prepared_batch: PreparedBatch, - ) -> BatchSessionState: + ) -> BatchSessionResumeState: """Open, upload, and close a batch session for a prepared package. Args: @@ -231,7 +250,7 @@ async def submit_prepared_batch( httpx.HTTPError: If uploading a part to its presigned URL fails. """ async with self.open_session(prepared_batch=prepared_batch) as session: - state = session.get_state() + state = session.resume_state() await session.upload_parts() return state @@ -242,7 +261,7 @@ async def submit_batch( form_code: FormSchema = FormSchema.FA3, offline_mode: bool = False, max_part_size: int = MAX_BATCH_PART_SIZE, - ) -> BatchSessionState: + ) -> BatchSessionResumeState: """Prepare and submit a batch in one call. Args: @@ -273,7 +292,7 @@ async def submit_batch( async def get_status( self, *, - session: str | BatchSessionState | AsyncBatchSessionClient, + session: str | BatchSessionResumeState | AsyncBatchSessionClient, ) -> SessionStatusResponse: """Fetch the current status of a batch session. @@ -292,7 +311,7 @@ async def get_status( async def list_invoices( self, *, - session: str | BatchSessionState | AsyncBatchSessionClient, + session: str | BatchSessionResumeState | AsyncBatchSessionClient, page_size: int = 10, continuation_token: str | None = None, ) -> SessionInvoicesResponse: @@ -308,7 +327,7 @@ async def list_invoices( async def list_failed_invoices( self, *, - session: str | BatchSessionState | AsyncBatchSessionClient, + session: str | BatchSessionResumeState | AsyncBatchSessionClient, page_size: int = 10, continuation_token: str | None = None, ) -> SessionInvoicesResponse: @@ -324,7 +343,7 @@ async def list_failed_invoices( async def get_upo( self, *, - session: str | BatchSessionState | AsyncBatchSessionClient, + session: str | BatchSessionResumeState | AsyncBatchSessionClient, upo_reference_number: str, ) -> bytes: """Download the collective UPO for a batch session. @@ -344,7 +363,7 @@ async def get_upo( async def wait_for_completion( self, *, - session: str | BatchSessionState | AsyncBatchSessionClient, + session: str | BatchSessionResumeState | AsyncBatchSessionClient, timeout: float = 120.0, poll_interval: float = 2.0, ) -> SessionStatusResponse: @@ -386,10 +405,10 @@ async def _poll() -> SessionStatusResponse: @staticmethod def _resolve_reference_number( - session: str | BatchSessionState | AsyncBatchSessionClient, + session: str | BatchSessionResumeState | AsyncBatchSessionClient, ) -> str: if isinstance(session, str): return session if isinstance(session, AsyncBatchSessionClient): - return session.get_state().reference_number + return session.resume_state().reference_number return session.reference_number diff --git a/src/ksef2/services/async_invoices.py b/src/ksef2/services/async_invoices.py index a6a65f6..bdf4fb3 100644 --- a/src/ksef2/services/async_invoices.py +++ b/src/ksef2/services/async_invoices.py @@ -10,7 +10,7 @@ from ksef2.core.async_protocols import AsyncMiddleware from ksef2.core.crypto import decrypt_aes_cbc from ksef2.core.polling import async_poll_until -from ksef2.core.stores import CertificateStore +from ksef2.core.stores import CertificateStoreProtocol from ksef2.domain.models.compression import CompressionType from ksef2.domain.models.invoices import ( ExportHandle, @@ -46,7 +46,7 @@ def __init__( self, transport: AsyncMiddleware, download_transport: AsyncMiddleware, - certificate_store: CertificateStore, + certificate_store: CertificateStoreProtocol, *, client: AsyncInvoicesClient | None = None, ensure_encryption_certificates_loaded: Callable[[], Awaitable[None]] diff --git a/src/ksef2/services/batch.py b/src/ksef2/services/batch.py index a82128b..84c2913 100644 --- a/src/ksef2/services/batch.py +++ b/src/ksef2/services/batch.py @@ -5,15 +5,21 @@ from collections.abc import Callable, Iterable from pathlib import Path -from typing import final +from typing import Protocol, final from ksef2.clients.batch import BatchSessionClient from ksef2.core import exceptions from ksef2.core.polling import poll_until from ksef2.core.protocols import Middleware -from ksef2.domain.models.batch import BatchInvoice, BatchSessionState, PreparedBatch +from ksef2.domain.models.batch import ( + BatchFileInfo, + BatchInvoice, + BatchSessionResumeState, + PreparedBatch, +) from ksef2.domain.models.session import ( FormSchema, + SessionEncryptionMaterial, SessionInvoicesResponse, SessionStatusResponse, ) @@ -27,6 +33,21 @@ ) +class BatchSessionOpener(Protocol): + def __call__( + self, + *, + batch_file: BatchFileInfo, + aes_key: bytes, + iv: bytes, + encrypted_key: bytes, + public_key_id: str | None = None, + form_code: FormSchema = FormSchema.FA3, + offline_mode: bool = False, + prepared_batch: PreparedBatch | None = None, + ) -> BatchSessionClient: ... + + @final class BatchService: """High-level workflow for preparing and sending invoice batches. @@ -47,8 +68,8 @@ def __init__( *, authed_transport: Middleware, upload_transport: Middleware, - get_encryption_key: Callable[[], tuple[bytes, bytes, bytes, str | None]], - open_batch_session: Callable[..., BatchSessionClient], + get_encryption_key: Callable[[], SessionEncryptionMaterial], + open_batch_session: BatchSessionOpener, ) -> None: self._invoice_eps = InvoicesEndpoints(authed_transport) self._session_eps = SessionEndpoints(authed_transport) @@ -82,13 +103,13 @@ def prepare_batch( KSeFEncryptionError: If key or part encryption fails. KSeFValidationError: If the invoice list or part size is invalid. """ - aes_key, iv, encrypted_key, public_key_id = self._get_encryption_key() + material = self._get_encryption_key() return prepare_batch_package( invoices=invoices, - aes_key=aes_key, - iv=iv, - encrypted_key=encrypted_key, - public_key_id=public_key_id, + aes_key=material.aes_key, + iv=material.iv, + encrypted_key=material.encrypted_key, + public_key_id=material.public_key_id, form_code=form_code, offline_mode=offline_mode, max_part_size=max_part_size, @@ -214,7 +235,7 @@ def submit_prepared_batch( self, *, prepared_batch: PreparedBatch, - ) -> BatchSessionState: + ) -> BatchSessionResumeState: """Open, upload, and close a batch session for a prepared package. Args: @@ -229,7 +250,7 @@ def submit_prepared_batch( httpx.HTTPError: If uploading a part to its presigned URL fails. """ with self.open_session(prepared_batch=prepared_batch) as session: - state = session.get_state() + state = session.resume_state() session.upload_parts() return state @@ -240,7 +261,7 @@ def submit_batch( form_code: FormSchema = FormSchema.FA3, offline_mode: bool = False, max_part_size: int = MAX_BATCH_PART_SIZE, - ) -> BatchSessionState: + ) -> BatchSessionResumeState: """Prepare and submit a batch in one call. Args: @@ -271,7 +292,7 @@ def submit_batch( def get_status( self, *, - session: str | BatchSessionState | BatchSessionClient, + session: str | BatchSessionResumeState | BatchSessionClient, ) -> SessionStatusResponse: """Fetch the current status of a batch session. @@ -290,7 +311,7 @@ def get_status( def list_invoices( self, *, - session: str | BatchSessionState | BatchSessionClient, + session: str | BatchSessionResumeState | BatchSessionClient, page_size: int = 10, continuation_token: str | None = None, ) -> SessionInvoicesResponse: @@ -306,7 +327,7 @@ def list_invoices( def list_failed_invoices( self, *, - session: str | BatchSessionState | BatchSessionClient, + session: str | BatchSessionResumeState | BatchSessionClient, page_size: int = 10, continuation_token: str | None = None, ) -> SessionInvoicesResponse: @@ -322,7 +343,7 @@ def list_failed_invoices( def get_upo( self, *, - session: str | BatchSessionState | BatchSessionClient, + session: str | BatchSessionResumeState | BatchSessionClient, upo_reference_number: str, ) -> bytes: """Download the collective UPO for a batch session. @@ -342,7 +363,7 @@ def get_upo( def wait_for_completion( self, *, - session: str | BatchSessionState | BatchSessionClient, + session: str | BatchSessionResumeState | BatchSessionClient, timeout: float = 120.0, poll_interval: float = 2.0, ) -> SessionStatusResponse: @@ -384,10 +405,10 @@ def _poll() -> SessionStatusResponse: @staticmethod def _resolve_reference_number( - session: str | BatchSessionState | BatchSessionClient, + session: str | BatchSessionResumeState | BatchSessionClient, ) -> str: if isinstance(session, str): return session if isinstance(session, BatchSessionClient): - return session.get_state().reference_number + return session.resume_state().reference_number return session.reference_number diff --git a/src/ksef2/services/invoices.py b/src/ksef2/services/invoices.py index fb62c71..2f2dd70 100644 --- a/src/ksef2/services/invoices.py +++ b/src/ksef2/services/invoices.py @@ -12,7 +12,7 @@ from ksef2.core.crypto import decrypt_aes_cbc from ksef2.core.polling import poll_until from ksef2.core.protocols import Middleware -from ksef2.core.stores import CertificateStore +from ksef2.core.stores import CertificateStoreProtocol from ksef2.domain.models.compression import CompressionType from ksef2.domain.models.invoices import ( ExportHandle, @@ -48,7 +48,7 @@ def __init__( self, transport: Middleware, download_transport: Middleware, - certificate_store: CertificateStore, + certificate_store: CertificateStoreProtocol, *, client: InvoicesClient | None = None, ensure_encryption_certificates_loaded: Callable[[], None] | None = None, diff --git a/tests/integration/test_batch_session.py b/tests/integration/test_batch_session.py index b1e5cb0..72f3ee4 100644 --- a/tests/integration/test_batch_session.py +++ b/tests/integration/test_batch_session.py @@ -3,7 +3,7 @@ import pytest from ksef2 import Client, Environment -from ksef2.domain.models import BatchSessionState +from ksef2.domain.models import BatchSessionResumeState from ksef2.domain.models.batch import ( BatchFileInfo, BatchFilePart, @@ -62,7 +62,7 @@ def test_open_batch_session(self, ksef_credentials: KSeFCredentials) -> None: assert upload_req.headers batch_session.upload_parts() - state = batch_session.get_state() + state = batch_session.resume_state() status = auth.batch.wait_for_completion( session=state, @@ -118,7 +118,7 @@ def test_batch_file_info_multiple_parts(self) -> None: assert batch_file.parts[4].ordinal_number == 5 def test_batch_session_state_serialization(self) -> None: - """Test that BatchSessionState can be serialized and restored.""" + """Test that BatchSessionResumeState can be serialized and restored.""" upload_requests = [ PartUploadRequest( ordinal_number=1, @@ -128,23 +128,22 @@ def test_batch_session_state_serialization(self) -> None: ) ] - state = BatchSessionState.from_encoded( + state = BatchSessionResumeState.from_encoded( reference_number="20250217-SB-TEST123456-ABCDEF1234-E9", aes_key=b"0123456789abcdef0123456789abcdef", iv=b"0123456789abcdef", - access_token="test-access-token", form_code=FormSchema.FA3, part_upload_requests=upload_requests, ) # Serialize to JSON - state_json = state.model_dump_json() + state_json = state.to_json() # Restore from JSON - restored = BatchSessionState.model_validate_json(state_json) + restored = BatchSessionResumeState.from_json(state_json) assert restored.reference_number == state.reference_number - assert restored.access_token == state.access_token + assert "access_token" not in restored.to_dict() assert restored.form_code == FormSchema.FA3 assert len(restored.part_upload_requests) == 1 assert restored.part_upload_requests[0].url == "https://example.com/upload/1" diff --git a/tests/integration/test_invoices_status.py b/tests/integration/test_invoices_status.py index 6033986..76af1ce 100644 --- a/tests/integration/test_invoices_status.py +++ b/tests/integration/test_invoices_status.py @@ -8,8 +8,6 @@ source .env.test && uv run pytest tests/integration/test_invoices_status.py -v -m integration """ -from __future__ import annotations - import pytest from ksef2 import Client, Environment, FormSchema @@ -88,7 +86,7 @@ def session_with_invoice(ksef_credentials: KSeFCredentials): with auth.online_session(form_code=FormSchema.FA3) as session: result = session.send_invoice(invoice_xml=load_test_invoice_xml()) invoice_ref = result.reference_number - session_ref = session.get_state().reference_number + session_ref = session.resume_state().reference_number yield client, access_token, session_ref, invoice_ref, session diff --git a/tests/integration/test_session_workflow.py b/tests/integration/test_session_workflow.py index c6fa4fc..9c95708 100644 --- a/tests/integration/test_session_workflow.py +++ b/tests/integration/test_session_workflow.py @@ -2,14 +2,12 @@ Covers: sessions.open_online (context manager), send_invoice, download_invoice, get_status, list_invoices, list_failed_invoices, get_invoice_upo_by_ksef_number, -get_invoice_upo_by_reference, get_state, sessions.resume. +get_invoice_upo_by_reference, resume_state, sessions.resume. Run with: uv run pytest tests/integration/test_session_workflow.py -v -m integration """ -from __future__ import annotations - import time import pytest @@ -18,7 +16,10 @@ from ksef2.clients.online import OnlineSessionClient from ksef2.core.tools import generate_nip, generate_pesel from ksef2.xades import generate_test_certificate -from ksef2.domain.models.session import OnlineSessionState, SessionStatusResponse +from ksef2.domain.models.session import ( + OnlineSessionResumeState, + SessionStatusResponse, +) from ksef2.domain.models.testdata import ( Identifier, Permission, @@ -81,7 +82,6 @@ def workflow_context(ksef_credentials: KSeFCredentials): cert=cert, private_key=private_key, ) - access_token = auth.access_token with auth.online_session(form_code=FormSchema.FA3) as session: result = session.send_invoice(invoice_xml=load_test_invoice_xml()) @@ -94,7 +94,6 @@ def workflow_context(ksef_credentials: KSeFCredentials): yield { "client": client, "auth": auth, - "access_token": access_token, "session": session, "invoice_ref": result.reference_number, "invoices_list": invoices_list, @@ -102,22 +101,21 @@ def workflow_context(ksef_credentials: KSeFCredentials): # --------------------------------------------------------------------------- -# get_state +# resume_state # --------------------------------------------------------------------------- @pytest.mark.integration -def test_get_state_returns_session_state(workflow_context): - """get_state returns a SessionState with all required fields.""" +def test_resume_state_returns_session_state(workflow_context): + """resume_state returns a session state with all required fields.""" session: OnlineSessionClient = workflow_context["session"] - state = session.get_state() + state = session.resume_state() - assert isinstance(state, OnlineSessionState) + assert isinstance(state, OnlineSessionResumeState) assert state.reference_number - assert state.access_token - assert state.aes_key - assert state.iv + assert state.aes_key.get_secret_value() + assert state.iv.get_secret_value() assert state.valid_until is not None assert state.form_code == FormSchema.FA3 @@ -189,18 +187,18 @@ def test_get_invoice_upo_by_reference(workflow_context): @pytest.mark.integration -def test_resume_session_from_state(workflow_context): +def test_resume_session_from_resume_state(workflow_context): """Resume a session from serialized state and use it.""" from ksef2.clients.authenticated import AuthenticatedClient auth: AuthenticatedClient = workflow_context["auth"] session: OnlineSessionClient = workflow_context["session"] - state = session.get_state() + state = session.resume_state() # Round-trip through JSON serialization - state_json = state.model_dump_json() - restored_state = OnlineSessionState.model_validate_json(state_json) + state_json = state.to_json() + restored_state = OnlineSessionResumeState.from_json(state_json) resumed = auth.resume_online_session(state=restored_state) @@ -261,7 +259,7 @@ def test_get_session_upo_by_reference(ksef_credentials: KSeFCredentials): with auth.online_session(form_code=FormSchema.FA3) as session: _ = session.send_invoice(invoice_xml=load_test_invoice_xml()) - state = session.get_state() + state = session.resume_state() resumed = auth.resume_online_session(state=state) diff --git a/tests/unit/clients/test_async_auth.py b/tests/unit/clients/test_async_auth.py index 787bbdc..913d3ad 100644 --- a/tests/unit/clients/test_async_auth.py +++ b/tests/unit/clients/test_async_auth.py @@ -18,7 +18,7 @@ from ksef2.core.routes import AuthRoutes from ksef2.core.stores import CertificateStore from ksef2.xades import generate_test_certificate -from ksef2.domain.models.auth import AuthTokens +from ksef2.domain.models.auth import AuthenticationResumeState, AuthTokens from ksef2.domain.models.encryption import PublicKeyCertificate from ksef2.infra.schema.api.supp.auth import InitTokenAuthenticationRequest from ksef2.infra.schema.api import spec @@ -45,6 +45,20 @@ def _token_store(certificate: PublicKeyCertificate) -> CertificateStore: class TestAsyncAuthClient: + def test_resume_rehydrates_authenticated_client( + self, + async_fake_transport: AsyncFakeTransport, + domain_auth_tokens: BaseFactory[AuthTokens], + ) -> None: + client = _build_auth_client(async_fake_transport) + auth_tokens = domain_auth_tokens.build() + state = AuthenticationResumeState.from_tokens(auth_tokens) + + result = client.resume(state) + + assert isinstance(result, AsyncAuthenticatedClient) + assert result.auth_tokens == auth_tokens + @patch("ksef2.clients.async_auth.encrypt_token", return_value=VALID_BASE64) def test_with_token( self, diff --git a/tests/unit/clients/test_async_base.py b/tests/unit/clients/test_async_base.py index 33f1bbc..85cc74d 100644 --- a/tests/unit/clients/test_async_base.py +++ b/tests/unit/clients/test_async_base.py @@ -1,8 +1,11 @@ import asyncio +from collections.abc import Iterable +from datetime import datetime from unittest.mock import AsyncMock, MagicMock, patch import httpx import pytest +from polyfactory import BaseFactory from ksef2.clients.async_auth import AsyncAuthClient from ksef2.clients.async_base import AsyncClient @@ -12,10 +15,40 @@ from ksef2.config import Environment, TimeoutConfig, TransportConfig from ksef2.core.exceptions import KSeFClientClosedError from ksef2.core.exceptions import KSeFUnsupportedEnvironmentError +from ksef2.domain.models.auth import AuthTokens +from ksef2.domain.models.encryption import ( + CertUsage, + CertUsageEnum, + PublicKeyCertificate, +) HTTPX_ASYNC_CLIENT_CLASS = httpx.AsyncClient +class CustomCertificateStore: + def __init__(self) -> None: + self.certificates: list[PublicKeyCertificate] = [] + + def load(self, certs: Iterable[PublicKeyCertificate]) -> None: + self.certificates = list(certs) + + def get_valid( + self, + usage: CertUsage | CertUsageEnum | str, + ) -> PublicKeyCertificate: + raise AssertionError(f"Unexpected certificate lookup for {usage}.") + + def needs_refresh( + self, + usage: CertUsage | CertUsageEnum | str, + *, + at: datetime | None = None, + ) -> bool: + _ = usage + _ = at + return True + + class TestAsyncClient: def test_accessors_return_expected_types(self) -> None: client = AsyncClient(environment=Environment.TEST) @@ -113,3 +146,50 @@ def test_accessors_raise_after_close( with pytest.raises(KSeFClientClosedError, match="Client is closed"): _ = client.authentication + + @patch("ksef2.clients.async_base.AsyncAuthClient") + def test_authentication_accessor_uses_custom_certificate_store( + self, + auth_client_cls: MagicMock, + ) -> None: + store = CustomCertificateStore() + client = AsyncClient( + environment=Environment.TEST, + http_client=AsyncMock(spec=HTTPX_ASYNC_CLIENT_CLASS), + certificate_store=store, + ) + + try: + _ = client.authentication + finally: + asyncio.run(client.aclose()) + + _, kwargs = auth_client_cls.call_args + assert kwargs["certificate_store"] is store + + @patch("ksef2.clients.async_base.AsyncAuthClient") + def test_authenticated_deprecated_wrapper_delegates_to_auth_branch( + self, + auth_client_cls: MagicMock, + domain_auth_tokens: BaseFactory[AuthTokens], + ) -> None: + store = CustomCertificateStore() + auth_branch = MagicMock() + auth_client_cls.return_value = auth_branch + auth_tokens = domain_auth_tokens.build() + client = AsyncClient( + environment=Environment.TEST, + http_client=AsyncMock(spec=HTTPX_ASYNC_CLIENT_CLASS), + certificate_store=store, + ) + + try: + with pytest.deprecated_call(match="AsyncClient.authenticated"): + _ = client.authenticated(auth_tokens) + finally: + asyncio.run(client.aclose()) + + _, kwargs = auth_client_cls.call_args + assert kwargs["certificate_store"] is store + state = auth_branch.resume.call_args.args[0] + assert state.to_tokens() == auth_tokens diff --git a/tests/unit/clients/test_async_batch.py b/tests/unit/clients/test_async_batch.py index b063374..d213605 100644 --- a/tests/unit/clients/test_async_batch.py +++ b/tests/unit/clients/test_async_batch.py @@ -15,7 +15,7 @@ BatchFileInfo, BatchFilePart, BatchPreparedPart, - BatchSessionState, + BatchSessionResumeState, PreparedBatch, ) from ksef2.domain.models.session import FormSchema @@ -70,7 +70,7 @@ class TestAsyncBatchSessionClient: def test_aclose_is_idempotent_and_keeps_reference_accessible( self, async_fake_transport: AsyncFakeTransport, - domain_batch_session_state: BaseFactory[BatchSessionState], + domain_batch_session_state: BaseFactory[BatchSessionResumeState], ) -> None: state = domain_batch_session_state.build() client = AsyncBatchSessionClient(async_fake_transport, state) @@ -86,7 +86,9 @@ def test_aclose_is_idempotent_and_keeps_reference_accessible( ) assert client.reference_number == state.reference_number - assert client.get_state() == state + assert client.resume_state() == state + with pytest.deprecated_call(match="get_state"): + assert client.get_state() == state with pytest.raises(KSeFClientClosedError, match="Session client is closed"): _ = client.part_upload_requests @@ -94,7 +96,7 @@ def test_aclose_is_idempotent_and_keeps_reference_accessible( def test_upload_parts_uses_attached_prepared_batch( self, async_fake_transport: AsyncFakeTransport, - domain_batch_session_state: BaseFactory[BatchSessionState], + domain_batch_session_state: BaseFactory[BatchSessionResumeState], ) -> None: state = domain_batch_session_state.build() prepared_batch = PreparedBatch( @@ -143,7 +145,7 @@ def test_upload_parts_uses_attached_prepared_batch( def test_async_context_manager_closes_batch_session_on_exit( self, async_fake_transport: AsyncFakeTransport, - domain_batch_session_state: BaseFactory[BatchSessionState], + domain_batch_session_state: BaseFactory[BatchSessionResumeState], ) -> None: state = domain_batch_session_state.build() async_fake_transport.enqueue(json_body={}) @@ -162,7 +164,7 @@ async def _run() -> None: def test_get_status_reads_session_status( self, async_fake_transport: AsyncFakeTransport, - domain_batch_session_state: BaseFactory[BatchSessionState], + domain_batch_session_state: BaseFactory[BatchSessionResumeState], inv_session_status_resp: BaseFactory[spec.SessionStatusResponse], ) -> None: state = domain_batch_session_state.build() @@ -186,7 +188,7 @@ def test_get_status_reads_session_status( def test_get_upo_downloads_collective_session_upo( self, async_fake_transport: AsyncFakeTransport, - domain_batch_session_state: BaseFactory[BatchSessionState], + domain_batch_session_state: BaseFactory[BatchSessionResumeState], ) -> None: state = domain_batch_session_state.build() client = AsyncBatchSessionClient(async_fake_transport, state) @@ -282,3 +284,26 @@ async def _run() -> None: assert async_fake_transport.calls[1].path == SessionRoutes.CLOSE_BATCH.format( referenceNumber=reference_number ) + + def test_resume_batch_session_context_manager_closes_session( + self, + async_fake_transport: AsyncFakeTransport, + domain_auth_tokens: BaseFactory[AuthTokens], + domain_batch_session_state: BaseFactory[BatchSessionResumeState], + ) -> None: + client = _build_authenticated_client( + async_fake_transport, + domain_auth_tokens.build(), + ) + state = domain_batch_session_state.build() + async_fake_transport.enqueue({}) + + async def _run() -> None: + async with client.resume_batch_session(state=state) as session: + assert session.resume_state() == state + + asyncio.run(_run()) + + assert async_fake_transport.calls[0].path == SessionRoutes.CLOSE_BATCH.format( + referenceNumber=state.reference_number + ) diff --git a/tests/unit/clients/test_async_online.py b/tests/unit/clients/test_async_online.py index cf5730e..6211725 100644 --- a/tests/unit/clients/test_async_online.py +++ b/tests/unit/clients/test_async_online.py @@ -1,4 +1,5 @@ import asyncio +from datetime import datetime, timedelta, timezone from unittest.mock import AsyncMock, patch import pytest @@ -10,19 +11,20 @@ KSeFClientClosedError, KSeFInvoiceProcessingTimeoutError, KSeFSessionError, + NoCertificateAvailableError, ) -from ksef2.core.routes import InvoiceRoutes, SessionRoutes +from ksef2.core.routes import EncryptionRoutes, InvoiceRoutes, SessionRoutes from ksef2.core.stores import CertificateStore -from ksef2.domain.models.auth import AuthTokens +from ksef2.domain.models.auth import AuthenticationResumeState, AuthTokens from ksef2.domain.models.encryption import PublicKeyCertificate -from ksef2.domain.models.session import FormSchema, OnlineSessionState +from ksef2.domain.models.session import FormSchema, OnlineSessionResumeState from ksef2.infra.schema.api import spec from tests.unit.fakes.transport import AsyncFakeTransport def _build_client( async_fake_transport: AsyncFakeTransport, - domain_online_session_state: BaseFactory[OnlineSessionState], + domain_online_session_state: BaseFactory[OnlineSessionResumeState], ) -> AsyncOnlineSessionClient: return AsyncOnlineSessionClient( async_fake_transport, domain_online_session_state.build() @@ -42,10 +44,23 @@ def _build_authenticated_client( class TestAsyncOnlineSessionClient: + def test_authenticated_client_resume_state_exports_authentication_state( + self, + async_fake_transport: AsyncFakeTransport, + domain_auth_tokens: BaseFactory[AuthTokens], + ) -> None: + auth_tokens = domain_auth_tokens.build() + client = _build_authenticated_client(async_fake_transport, auth_tokens) + + state = client.resume_state() + + assert isinstance(state, AuthenticationResumeState) + assert state.to_tokens() == auth_tokens + def test_context_manager_reraises_close_failure_on_clean_exit( self, async_fake_transport: AsyncFakeTransport, - domain_online_session_state: BaseFactory[OnlineSessionState], + domain_online_session_state: BaseFactory[OnlineSessionResumeState], ) -> None: client = _build_client(async_fake_transport, domain_online_session_state) client.aclose = AsyncMock(side_effect=KSeFSessionError("close failed")) # type: ignore[method-assign] @@ -60,7 +75,7 @@ async def _run() -> None: def test_aclose_is_idempotent_and_blocks_further_calls( self, async_fake_transport: AsyncFakeTransport, - domain_online_session_state: BaseFactory[OnlineSessionState], + domain_online_session_state: BaseFactory[OnlineSessionResumeState], ) -> None: state = domain_online_session_state.build() client = AsyncOnlineSessionClient(async_fake_transport, state) @@ -83,7 +98,7 @@ def test_aclose_is_idempotent_and_blocks_further_calls( def test_wait_for_invoice_ready_returns_processed_status( self, async_fake_transport: AsyncFakeTransport, - domain_online_session_state: BaseFactory[OnlineSessionState], + domain_online_session_state: BaseFactory[OnlineSessionResumeState], inv_session_invoice_status_resp: BaseFactory[spec.SessionInvoiceStatusResponse], ) -> None: client = _build_client(async_fake_transport, domain_online_session_state) @@ -129,7 +144,7 @@ def test_wait_for_invoice_ready_returns_processed_status( def test_wait_for_invoice_ready_raises_on_terminal_failure( self, async_fake_transport: AsyncFakeTransport, - domain_online_session_state: BaseFactory[OnlineSessionState], + domain_online_session_state: BaseFactory[OnlineSessionResumeState], inv_session_invoice_status_resp: BaseFactory[spec.SessionInvoiceStatusResponse], ) -> None: client = _build_client(async_fake_transport, domain_online_session_state) @@ -156,7 +171,7 @@ def test_wait_for_invoice_ready_raises_on_terminal_failure( def test_wait_for_invoice_ready_raises_on_timeout( self, async_fake_transport: AsyncFakeTransport, - domain_online_session_state: BaseFactory[OnlineSessionState], + domain_online_session_state: BaseFactory[OnlineSessionResumeState], inv_session_invoice_status_resp: BaseFactory[spec.SessionInvoiceStatusResponse], ) -> None: client = _build_client(async_fake_transport, domain_online_session_state) @@ -182,7 +197,7 @@ def test_wait_for_invoice_ready_raises_on_timeout( def test_send_invoice_and_wait( self, async_fake_transport: AsyncFakeTransport, - domain_online_session_state: BaseFactory[OnlineSessionState], + domain_online_session_state: BaseFactory[OnlineSessionResumeState], inv_send_resp: BaseFactory[spec.SendInvoiceResponse], inv_session_invoice_status_resp: BaseFactory[spec.SessionInvoiceStatusResponse], ) -> None: @@ -223,6 +238,198 @@ def test_send_invoice_and_wait( class TestAsyncAuthenticatedOnlineSession: + @patch( + "ksef2.clients.async_authenticated.encrypt_symmetric_key", + return_value=b"enc-key", + ) + @patch( + "ksef2.clients.async_authenticated.generate_session_key", + return_value=(b"k" * 32, b"v" * 16), + ) + def test_get_encryption_key_uses_fresh_symmetric_key_certificate_without_refresh( + self, + _mock_generate_session_key, + _mock_encrypt_symmetric_key, + async_fake_transport: AsyncFakeTransport, + domain_auth_tokens: BaseFactory[AuthTokens], + domain_public_key_cert: BaseFactory[PublicKeyCertificate], + ) -> None: + store = CertificateStore() + store.load( + [ + domain_public_key_cert.build( + usage=["symmetric_key_encryption"], + ) + ] + ) + client = _build_authenticated_client( + async_fake_transport, + domain_auth_tokens.build(), + certificate_store=store, + ) + + async def _run() -> tuple[bytes, bytes, bytes]: + return await client.get_encryption_key() + + aes_key, iv, encrypted_key = asyncio.run(_run()) + + assert aes_key == b"k" * 32 + assert iv == b"v" * 16 + assert encrypted_key == b"enc-key" + assert async_fake_transport.calls == [] + + @patch( + "ksef2.clients.async_authenticated.encrypt_symmetric_key", + return_value=b"enc-key", + ) + @patch( + "ksef2.clients.async_authenticated.generate_session_key", + return_value=(b"k" * 32, b"v" * 16), + ) + def test_get_encryption_key_refreshes_stale_symmetric_key_certificate( + self, + _mock_generate_session_key, + _mock_encrypt_symmetric_key, + async_fake_transport: AsyncFakeTransport, + domain_auth_tokens: BaseFactory[AuthTokens], + domain_public_key_cert: BaseFactory[PublicKeyCertificate], + public_key_cert: BaseFactory[spec.PublicKeyCertificate], + ) -> None: + store = CertificateStore(refresh_after=timedelta(0)) + store.load( + [ + domain_public_key_cert.build( + public_key_id="old-public-key-id", + usage=["symmetric_key_encryption"], + ) + ] + ) + refreshed_certificate = public_key_cert.build( + validTo=datetime.now(timezone.utc) + timedelta(days=30), + usage=[spec.PublicKeyCertificateUsage.SymmetricKeyEncryption], + ) + async_fake_transport.enqueue( + json_body=[refreshed_certificate.model_dump(mode="json")] + ) + client = _build_authenticated_client( + async_fake_transport, + domain_auth_tokens.build(), + certificate_store=store, + ) + + async def _run() -> tuple[bytes, bytes, bytes]: + return await client.get_encryption_key() + + aes_key, iv, encrypted_key = asyncio.run(_run()) + + assert aes_key == b"k" * 32 + assert iv == b"v" * 16 + assert encrypted_key == b"enc-key" + assert async_fake_transport.calls[0].method == "GET" + assert async_fake_transport.calls[0].path == ( + EncryptionRoutes.PUBLIC_KEY_CERTIFICATES + ) + assert ( + store.get_valid("symmetric_key_encryption").public_key_id + == refreshed_certificate.publicKeyId + ) + + @patch( + "ksef2.clients.async_authenticated.encrypt_symmetric_key", + return_value=b"enc-key", + ) + @patch( + "ksef2.clients.async_authenticated.generate_session_key", + return_value=(b"k" * 32, b"v" * 16), + ) + def test_get_encryption_key_fetches_certificates_when_store_lacks_symmetric_key_usage( + self, + _mock_generate_session_key, + _mock_encrypt_symmetric_key, + async_fake_transport: AsyncFakeTransport, + domain_auth_tokens: BaseFactory[AuthTokens], + domain_public_key_cert: BaseFactory[PublicKeyCertificate], + public_key_cert: BaseFactory[spec.PublicKeyCertificate], + ) -> None: + store = CertificateStore() + store.load( + [ + domain_public_key_cert.build( + usage=["ksef_token_encryption"], + ) + ] + ) + refreshed_certificate = public_key_cert.build( + usage=[spec.PublicKeyCertificateUsage.SymmetricKeyEncryption] + ) + async_fake_transport.enqueue( + json_body=[refreshed_certificate.model_dump(mode="json")] + ) + client = _build_authenticated_client( + async_fake_transport, + domain_auth_tokens.build(), + certificate_store=store, + ) + + async def _run() -> tuple[bytes, bytes, bytes]: + return await client.get_encryption_key() + + aes_key, iv, encrypted_key = asyncio.run(_run()) + + assert aes_key == b"k" * 32 + assert iv == b"v" * 16 + assert encrypted_key == b"enc-key" + assert async_fake_transport.calls[0].method == "GET" + assert async_fake_transport.calls[0].path == ( + EncryptionRoutes.PUBLIC_KEY_CERTIFICATES + ) + assert ( + store.get_valid("symmetric_key_encryption").public_key_id + == refreshed_certificate.publicKeyId + ) + + def test_get_encryption_key_preserves_missing_symmetric_key_error_after_refresh( + self, + async_fake_transport: AsyncFakeTransport, + domain_auth_tokens: BaseFactory[AuthTokens], + domain_public_key_cert: BaseFactory[PublicKeyCertificate], + public_key_cert: BaseFactory[spec.PublicKeyCertificate], + ) -> None: + store = CertificateStore() + store.load( + [ + domain_public_key_cert.build( + usage=["ksef_token_encryption"], + ) + ] + ) + async_fake_transport.enqueue( + json_body=[ + public_key_cert.build( + usage=[spec.PublicKeyCertificateUsage.KsefTokenEncryption] + ).model_dump(mode="json") + ] + ) + client = _build_authenticated_client( + async_fake_transport, + domain_auth_tokens.build(), + certificate_store=store, + ) + + async def _run() -> tuple[bytes, bytes, bytes]: + return await client.get_encryption_key() + + with pytest.raises( + NoCertificateAvailableError, + match="No valid certificate for usage: symmetric_key_encryption", + ): + asyncio.run(_run()) + + assert async_fake_transport.calls[0].method == "GET" + assert async_fake_transport.calls[0].path == ( + EncryptionRoutes.PUBLIC_KEY_CERTIFICATES + ) + @patch( "ksef2.clients.async_authenticated.encrypt_symmetric_key", return_value=b"enc-key", @@ -269,7 +476,7 @@ async def _run() -> AsyncOnlineSessionClient: assert call.headers == {"Authorization": "Bearer fake-access-token"} assert call.json is not None assert call.json["encryption"]["publicKeyId"] == store.all()[0].public_key_id - assert session_client.get_state().access_token == "fake-access-token" + assert "access_token" not in session_client.resume_state().to_dict() @patch( "ksef2.clients.async_authenticated.encrypt_symmetric_key", @@ -308,7 +515,7 @@ def test_online_session_context_manager_opens_and_closes_session( async def _run() -> None: async with client.online_session(form_code=FormSchema.FA3) as session: - assert session.get_state().reference_number == reference_number + assert session.resume_state().reference_number == reference_number asyncio.run(_run()) @@ -366,3 +573,28 @@ async def _run() -> None: assert async_fake_transport.calls[1].path == ( SessionRoutes.TERMINATE_ONLINE.format(referenceNumber=reference_number) ) + + def test_resume_online_session_context_manager_closes_session( + self, + async_fake_transport: AsyncFakeTransport, + domain_auth_tokens: BaseFactory[AuthTokens], + domain_online_session_state: BaseFactory[OnlineSessionResumeState], + ) -> None: + client = _build_authenticated_client( + async_fake_transport, + domain_auth_tokens.build(), + ) + state = domain_online_session_state.build() + async_fake_transport.enqueue({}) + + async def _run() -> None: + async with client.resume_online_session(state=state) as session: + assert session.resume_state() == state + + asyncio.run(_run()) + + assert async_fake_transport.calls[0].path == ( + SessionRoutes.TERMINATE_ONLINE.format( + referenceNumber=state.reference_number + ) + ) diff --git a/tests/unit/clients/test_auth.py b/tests/unit/clients/test_auth.py index c187432..6ec34c5 100644 --- a/tests/unit/clients/test_auth.py +++ b/tests/unit/clients/test_auth.py @@ -75,6 +75,20 @@ def _token_store(certificate: PublicKeyCertificate) -> CertificateStore: class TestAuthClient: + def test_resume_rehydrates_authenticated_client( + self, + fake_transport: FakeTransport, + domain_auth_tokens: BaseFactory[domain_auth.AuthTokens], + ) -> None: + client = _build_auth_client(fake_transport) + auth_tokens = domain_auth_tokens.build() + state = domain_auth.AuthenticationResumeState.from_tokens(auth_tokens) + + result = client.resume(state) + + assert isinstance(result, AuthenticatedClient) + assert result.auth_tokens == auth_tokens + @patch("ksef2.clients.auth.encrypt_token", return_value=VALID_BASE64) def test_with_token( self, diff --git a/tests/unit/clients/test_authenticated_client.py b/tests/unit/clients/test_authenticated_client.py index 120a1a0..88a2142 100644 --- a/tests/unit/clients/test_authenticated_client.py +++ b/tests/unit/clients/test_authenticated_client.py @@ -1,6 +1,7 @@ from datetime import datetime, timedelta, timezone from unittest.mock import MagicMock, patch +import pytest from polyfactory import BaseFactory from ksef2.clients.authenticated import AuthenticatedClient @@ -12,19 +13,20 @@ from ksef2.clients.permissions import PermissionsClient from ksef2.clients.session_management import SessionManagementClient from ksef2.clients.tokens import TokensClient +from ksef2.core.exceptions import NoCertificateAvailableError from ksef2.core.routes import EncryptionRoutes, SessionRoutes, TokenRoutes from ksef2.core.stores import CertificateStore -from ksef2.domain.models.auth import AuthTokens +from ksef2.domain.models.auth import AuthenticationResumeState, AuthTokens from ksef2.domain.models.batch import ( BatchEncryptionData, BatchFileInfo, BatchFilePart, BatchPreparedPart, - BatchSessionState, + BatchSessionResumeState, PreparedBatch, ) from ksef2.domain.models.encryption import PublicKeyCertificate -from ksef2.domain.models.session import FormSchema, OnlineSessionState +from ksef2.domain.models.session import FormSchema, OnlineSessionResumeState from ksef2.infra.schema.api import spec from ksef2.services.batch import BatchService from ksef2.services.invoices import InvoicesService @@ -58,6 +60,19 @@ def test_properties_expose_tokens( assert client.access_token == _TOKEN assert client.refresh_token == "fake-refresh-token" + def test_resume_state_exports_authentication_state( + self, + fake_transport: FakeTransport, + domain_auth_tokens: BaseFactory[AuthTokens], + ) -> None: + auth_tokens = domain_auth_tokens.build() + client = _build_client(fake_transport, auth_tokens) + + state = client.resume_state() + + assert isinstance(state, AuthenticationResumeState) + assert state.to_tokens() == auth_tokens + def test_leaf_accessors_return_expected_types( self, fake_transport: FakeTransport, @@ -159,6 +174,171 @@ def test_get_encryption_key_fetches_certificates_when_store_empty( assert fake_transport.calls[0].method == "GET" assert fake_transport.calls[0].path == EncryptionRoutes.PUBLIC_KEY_CERTIFICATES + @patch("ksef2.clients.authenticated.encrypt_symmetric_key", return_value=b"enc-key") + @patch( + "ksef2.clients.authenticated.generate_session_key", + return_value=(b"k" * 32, b"v" * 16), + ) + def test_get_encryption_key_uses_fresh_symmetric_key_certificate_without_refresh( + self, + _mock_generate_session_key: MagicMock, + _mock_encrypt_symmetric_key: MagicMock, + fake_transport: FakeTransport, + domain_auth_tokens: BaseFactory[AuthTokens], + domain_public_key_cert: BaseFactory[PublicKeyCertificate], + ) -> None: + store = CertificateStore() + store.load( + [ + domain_public_key_cert.build( + usage=["symmetric_key_encryption"], + ) + ] + ) + client = _build_client( + fake_transport, + domain_auth_tokens.build(), + certificate_store=store, + ) + + aes_key, iv, encrypted_key = client.get_encryption_key() + + assert aes_key == b"k" * 32 + assert iv == b"v" * 16 + assert encrypted_key == b"enc-key" + assert fake_transport.calls == [] + + @patch("ksef2.clients.authenticated.encrypt_symmetric_key", return_value=b"enc-key") + @patch( + "ksef2.clients.authenticated.generate_session_key", + return_value=(b"k" * 32, b"v" * 16), + ) + def test_get_encryption_key_refreshes_stale_symmetric_key_certificate( + self, + _mock_generate_session_key: MagicMock, + _mock_encrypt_symmetric_key: MagicMock, + fake_transport: FakeTransport, + domain_auth_tokens: BaseFactory[AuthTokens], + domain_public_key_cert: BaseFactory[PublicKeyCertificate], + public_key_cert: BaseFactory[spec.PublicKeyCertificate], + ) -> None: + store = CertificateStore(refresh_after=timedelta(0)) + store.load( + [ + domain_public_key_cert.build( + public_key_id="old-public-key-id", + usage=["symmetric_key_encryption"], + ) + ] + ) + refreshed_certificate = public_key_cert.build( + validTo=datetime.now(timezone.utc) + timedelta(days=30), + usage=[spec.PublicKeyCertificateUsage.SymmetricKeyEncryption], + ) + fake_transport.enqueue( + json_body=[refreshed_certificate.model_dump(mode="json")] + ) + client = _build_client( + fake_transport, + domain_auth_tokens.build(), + certificate_store=store, + ) + + aes_key, iv, encrypted_key = client.get_encryption_key() + + assert aes_key == b"k" * 32 + assert iv == b"v" * 16 + assert encrypted_key == b"enc-key" + assert fake_transport.calls[0].method == "GET" + assert fake_transport.calls[0].path == EncryptionRoutes.PUBLIC_KEY_CERTIFICATES + assert ( + store.get_valid("symmetric_key_encryption").public_key_id + == refreshed_certificate.publicKeyId + ) + + @patch("ksef2.clients.authenticated.encrypt_symmetric_key", return_value=b"enc-key") + @patch( + "ksef2.clients.authenticated.generate_session_key", + return_value=(b"k" * 32, b"v" * 16), + ) + def test_get_encryption_key_fetches_certificates_when_store_lacks_symmetric_key_usage( + self, + _mock_generate_session_key: MagicMock, + _mock_encrypt_symmetric_key: MagicMock, + fake_transport: FakeTransport, + domain_auth_tokens: BaseFactory[AuthTokens], + domain_public_key_cert: BaseFactory[PublicKeyCertificate], + public_key_cert: BaseFactory[spec.PublicKeyCertificate], + ) -> None: + store = CertificateStore() + store.load( + [ + domain_public_key_cert.build( + usage=["ksef_token_encryption"], + ) + ] + ) + refreshed_certificate = public_key_cert.build( + usage=[spec.PublicKeyCertificateUsage.SymmetricKeyEncryption] + ) + fake_transport.enqueue( + json_body=[refreshed_certificate.model_dump(mode="json")] + ) + client = _build_client( + fake_transport, + domain_auth_tokens.build(), + certificate_store=store, + ) + + aes_key, iv, encrypted_key = client.get_encryption_key() + + assert aes_key == b"k" * 32 + assert iv == b"v" * 16 + assert encrypted_key == b"enc-key" + assert fake_transport.calls[0].method == "GET" + assert fake_transport.calls[0].path == EncryptionRoutes.PUBLIC_KEY_CERTIFICATES + assert ( + store.get_valid("symmetric_key_encryption").public_key_id + == refreshed_certificate.publicKeyId + ) + + def test_get_encryption_key_preserves_missing_symmetric_key_error_after_refresh( + self, + fake_transport: FakeTransport, + domain_auth_tokens: BaseFactory[AuthTokens], + domain_public_key_cert: BaseFactory[PublicKeyCertificate], + public_key_cert: BaseFactory[spec.PublicKeyCertificate], + ) -> None: + store = CertificateStore() + store.load( + [ + domain_public_key_cert.build( + usage=["ksef_token_encryption"], + ) + ] + ) + fake_transport.enqueue( + json_body=[ + public_key_cert.build( + usage=[spec.PublicKeyCertificateUsage.KsefTokenEncryption] + ).model_dump(mode="json") + ] + ) + client = _build_client( + fake_transport, + domain_auth_tokens.build(), + certificate_store=store, + ) + + with pytest.raises( + NoCertificateAvailableError, + match="No valid certificate for usage: symmetric_key_encryption", + ): + client.get_encryption_key() + + assert fake_transport.calls[0].method == "GET" + assert fake_transport.calls[0].path == EncryptionRoutes.PUBLIC_KEY_CERTIFICATES + @patch("ksef2.clients.authenticated.encrypt_symmetric_key", return_value=b"enc-key") @patch( "ksef2.clients.authenticated.generate_session_key", @@ -197,7 +377,7 @@ def test_online_session_uses_bearer_transport_and_returns_client( assert call.headers == {"Authorization": f"Bearer {_TOKEN}"} assert call.json is not None assert call.json["encryption"]["publicKeyId"] == store.all()[0].public_key_id - assert session_client.get_state().access_token == _TOKEN + assert "access_token" not in session_client.resume_state().to_dict() @patch("ksef2.clients.authenticated.encrypt_symmetric_key", return_value=b"enc-key") @patch( @@ -239,7 +419,8 @@ def test_batch_session_uses_bearer_transport_and_returns_client( assert call.headers == {"Authorization": f"Bearer {_TOKEN}"} assert call.json is not None assert call.json["encryption"]["publicKeyId"] == store.all()[0].public_key_id - assert batch_client.access_token == _TOKEN + with pytest.deprecated_call(match="BatchSessionClient.access_token"): + assert batch_client.access_token == _TOKEN def test_open_batch_session_uses_supplied_encryption_material( self, @@ -320,26 +501,34 @@ def test_resume_online_session_reuses_authenticated_transport( self, fake_transport: FakeTransport, domain_auth_tokens: BaseFactory[AuthTokens], - domain_online_session_state: BaseFactory[OnlineSessionState], + domain_online_session_state: BaseFactory[OnlineSessionResumeState], ) -> None: client = _build_client(fake_transport, domain_auth_tokens.build()) state = domain_online_session_state.build() + fake_transport.enqueue({}) - resumed = client.resume_online_session(state) + with client.resume_online_session(state) as resumed: + assert isinstance(resumed, OnlineSessionClient) + assert resumed.resume_state() == state - assert isinstance(resumed, OnlineSessionClient) - assert resumed.get_state() == state + assert fake_transport.calls[0].path == SessionRoutes.TERMINATE_ONLINE.format( + referenceNumber=state.reference_number + ) def test_resume_batch_session_reuses_authenticated_transport( self, fake_transport: FakeTransport, domain_auth_tokens: BaseFactory[AuthTokens], - domain_batch_session_state: BaseFactory[BatchSessionState], + domain_batch_session_state: BaseFactory[BatchSessionResumeState], ) -> None: client = _build_client(fake_transport, domain_auth_tokens.build()) state = domain_batch_session_state.build() + fake_transport.enqueue({}) - resumed = client.resume_batch_session(state) + with client.resume_batch_session(state) as resumed: + assert isinstance(resumed, BatchSessionClient) + assert resumed.resume_state() == state - assert isinstance(resumed, BatchSessionClient) - assert resumed.get_state() == state + assert fake_transport.calls[0].path == SessionRoutes.CLOSE_BATCH.format( + referenceNumber=state.reference_number + ) diff --git a/tests/unit/clients/test_base.py b/tests/unit/clients/test_base.py index 0d006fe..4b1a3e8 100644 --- a/tests/unit/clients/test_base.py +++ b/tests/unit/clients/test_base.py @@ -1,7 +1,10 @@ +from collections.abc import Iterable +from datetime import datetime from unittest.mock import MagicMock, patch import httpx import pytest +from polyfactory import BaseFactory from ksef2.clients.base import Client from ksef2.clients.testdata import TestDataClient as KSeFTestDataClient @@ -10,10 +13,40 @@ KSeFClientClosedError, KSeFUnsupportedEnvironmentError, ) +from ksef2.domain.models.auth import AuthTokens +from ksef2.domain.models.encryption import ( + CertUsage, + CertUsageEnum, + PublicKeyCertificate, +) HTTPX_CLIENT_CLASS = httpx.Client +class CustomCertificateStore: + def __init__(self) -> None: + self.certificates: list[PublicKeyCertificate] = [] + + def load(self, certs: Iterable[PublicKeyCertificate]) -> None: + self.certificates = list(certs) + + def get_valid( + self, + usage: CertUsage | CertUsageEnum | str, + ) -> PublicKeyCertificate: + raise AssertionError(f"Unexpected certificate lookup for {usage}.") + + def needs_refresh( + self, + usage: CertUsage | CertUsageEnum | str, + *, + at: datetime | None = None, + ) -> bool: + _ = usage + _ = at + return True + + class TestClient: def test_testdata_accessor_uses_client( self, @@ -101,3 +134,44 @@ def test_accessors_raise_after_close( with pytest.raises(KSeFClientClosedError, match="Client is closed"): _ = client.authentication + + @patch("ksef2.clients.base.AuthClient") + def test_authentication_accessor_uses_custom_certificate_store( + self, + auth_client_cls: MagicMock, + ) -> None: + store = CustomCertificateStore() + client = Client( + environment=Environment.TEST, + http_client=MagicMock(spec=HTTPX_CLIENT_CLASS), + certificate_store=store, + ) + + _ = client.authentication + + _, kwargs = auth_client_cls.call_args + assert kwargs["certificate_store"] is store + + @patch("ksef2.clients.base.AuthClient") + def test_authenticated_deprecated_wrapper_delegates_to_auth_branch( + self, + auth_client_cls: MagicMock, + domain_auth_tokens: BaseFactory[AuthTokens], + ) -> None: + store = CustomCertificateStore() + auth_branch = MagicMock() + auth_client_cls.return_value = auth_branch + auth_tokens = domain_auth_tokens.build() + client = Client( + environment=Environment.TEST, + http_client=MagicMock(spec=HTTPX_CLIENT_CLASS), + certificate_store=store, + ) + + with pytest.deprecated_call(match="Client.authenticated"): + _ = client.authenticated(auth_tokens) + + _, kwargs = auth_client_cls.call_args + assert kwargs["certificate_store"] is store + state = auth_branch.resume.call_args.args[0] + assert state.to_tokens() == auth_tokens diff --git a/tests/unit/clients/test_batch.py b/tests/unit/clients/test_batch.py index deebad8..9159fca 100644 --- a/tests/unit/clients/test_batch.py +++ b/tests/unit/clients/test_batch.py @@ -9,7 +9,7 @@ BatchFileInfo, BatchFilePart, BatchPreparedPart, - BatchSessionState, + BatchSessionResumeState, PreparedBatch, ) from ksef2.core.crypto import sha256_b64 @@ -21,7 +21,7 @@ class TestBatchSessionClient: def test_close_is_idempotent_and_keeps_reference_accessible( self, fake_transport: FakeTransport, - domain_batch_session_state: BaseFactory[BatchSessionState], + domain_batch_session_state: BaseFactory[BatchSessionResumeState], ) -> None: state = domain_batch_session_state.build() client = BatchSessionClient(fake_transport, state) @@ -37,7 +37,9 @@ def test_close_is_idempotent_and_keeps_reference_accessible( ) assert client.reference_number == state.reference_number - assert client.get_state() == state + assert client.resume_state() == state + with pytest.deprecated_call(match="get_state"): + assert client.get_state() == state with pytest.raises(KSeFClientClosedError, match="Session client is closed"): _ = client.part_upload_requests @@ -45,7 +47,7 @@ def test_close_is_idempotent_and_keeps_reference_accessible( def test_upload_parts_uses_attached_prepared_batch( self, fake_transport: FakeTransport, - domain_batch_session_state: BaseFactory[BatchSessionState], + domain_batch_session_state: BaseFactory[BatchSessionResumeState], ) -> None: state = domain_batch_session_state.build() prepared_batch = PreparedBatch( @@ -94,7 +96,7 @@ def test_upload_parts_uses_attached_prepared_batch( def test_context_manager_closes_batch_session_on_exit( self, fake_transport: FakeTransport, - domain_batch_session_state: BaseFactory[BatchSessionState], + domain_batch_session_state: BaseFactory[BatchSessionResumeState], ) -> None: state = domain_batch_session_state.build() fake_transport.enqueue(json_body={}) @@ -110,7 +112,7 @@ def test_context_manager_closes_batch_session_on_exit( def test_get_status_reads_session_status( self, fake_transport: FakeTransport, - domain_batch_session_state: BaseFactory[BatchSessionState], + domain_batch_session_state: BaseFactory[BatchSessionResumeState], inv_session_status_resp: BaseFactory[spec.SessionStatusResponse], ) -> None: state = domain_batch_session_state.build() @@ -132,7 +134,7 @@ def test_get_status_reads_session_status( def test_list_invoices_reads_batch_session_invoice_page( self, fake_transport: FakeTransport, - domain_batch_session_state: BaseFactory[BatchSessionState], + domain_batch_session_state: BaseFactory[BatchSessionResumeState], inv_session_invoices_resp: BaseFactory[spec.SessionInvoicesResponse], inv_session_invoice_status_resp: BaseFactory[spec.SessionInvoiceStatusResponse], ) -> None: @@ -163,7 +165,7 @@ def test_list_invoices_reads_batch_session_invoice_page( def test_get_upo_downloads_collective_session_upo( self, fake_transport: FakeTransport, - domain_batch_session_state: BaseFactory[BatchSessionState], + domain_batch_session_state: BaseFactory[BatchSessionResumeState], ) -> None: state = domain_batch_session_state.build() client = BatchSessionClient(fake_transport, state) diff --git a/tests/unit/clients/test_online.py b/tests/unit/clients/test_online.py index 2dd0a17..0c476b3 100644 --- a/tests/unit/clients/test_online.py +++ b/tests/unit/clients/test_online.py @@ -10,14 +10,14 @@ KSeFSessionError, ) from ksef2.core.routes import InvoiceRoutes, SessionRoutes -from ksef2.domain.models.session import OnlineSessionState +from ksef2.domain.models.session import OnlineSessionResumeState from ksef2.infra.schema.api import spec from tests.unit.fakes.transport import FakeTransport def _build_client( fake_transport: FakeTransport, - domain_online_session_state: BaseFactory[OnlineSessionState], + domain_online_session_state: BaseFactory[OnlineSessionResumeState], ) -> OnlineSessionClient: return OnlineSessionClient(fake_transport, domain_online_session_state.build()) @@ -26,7 +26,7 @@ class TestOnlineSessionClient: def test_close_is_idempotent_and_blocks_further_calls( self, fake_transport: FakeTransport, - domain_online_session_state: BaseFactory[OnlineSessionState], + domain_online_session_state: BaseFactory[OnlineSessionResumeState], ) -> None: state = domain_online_session_state.build() client = OnlineSessionClient(fake_transport, state) @@ -47,7 +47,7 @@ def test_close_is_idempotent_and_blocks_further_calls( def test_wait_for_invoice_ready_returns_processed_status( self, fake_transport: FakeTransport, - domain_online_session_state: BaseFactory[OnlineSessionState], + domain_online_session_state: BaseFactory[OnlineSessionResumeState], inv_session_invoice_status_resp: BaseFactory[spec.SessionInvoiceStatusResponse], ) -> None: client = _build_client(fake_transport, domain_online_session_state) @@ -91,7 +91,7 @@ def test_wait_for_invoice_ready_returns_processed_status( def test_wait_for_invoice_ready_raises_on_terminal_failure( self, fake_transport: FakeTransport, - domain_online_session_state: BaseFactory[OnlineSessionState], + domain_online_session_state: BaseFactory[OnlineSessionResumeState], inv_session_invoice_status_resp: BaseFactory[spec.SessionInvoiceStatusResponse], ) -> None: client = _build_client(fake_transport, domain_online_session_state) @@ -116,7 +116,7 @@ def test_wait_for_invoice_ready_raises_on_terminal_failure( def test_wait_for_invoice_ready_raises_on_timeout( self, fake_transport: FakeTransport, - domain_online_session_state: BaseFactory[OnlineSessionState], + domain_online_session_state: BaseFactory[OnlineSessionResumeState], inv_session_invoice_status_resp: BaseFactory[spec.SessionInvoiceStatusResponse], ) -> None: client = _build_client(fake_transport, domain_online_session_state) @@ -141,7 +141,7 @@ def test_wait_for_invoice_ready_raises_on_timeout( def test_send_invoice_and_wait( self, fake_transport: FakeTransport, - domain_online_session_state: BaseFactory[OnlineSessionState], + domain_online_session_state: BaseFactory[OnlineSessionResumeState], inv_send_resp: BaseFactory[spec.SendInvoiceResponse], inv_session_invoice_status_resp: BaseFactory[spec.SessionInvoiceStatusResponse], ) -> None: diff --git a/tests/unit/factories/session.py b/tests/unit/factories/session.py index ffc74dc..cdbb177 100644 --- a/tests/unit/factories/session.py +++ b/tests/unit/factories/session.py @@ -77,20 +77,22 @@ class DomainPartUploadRequestFactory(ModelFactory[domain_batch.PartUploadRequest @register_fixture(name="domain_online_session_state") -class DomainOnlineSessionStateFactory(ModelFactory[domain_session.OnlineSessionState]): +class DomainOnlineSessionResumeStateFactory( + ModelFactory[domain_session.OnlineSessionResumeState] +): reference_number: str = "20250625-SO-2C3E6C8000-B675CF5D68-07" aes_key: str = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=" iv: str = "MDEyMzQ1Njc4OWFiY2RlZg==" - access_token: str = "fake-access-token" form_code: domain_session.FormSchema = domain_session.FormSchema.FA3 @register_fixture(name="domain_batch_session_state") -class DomainBatchSessionStateFactory(ModelFactory[domain_batch.BatchSessionState]): +class DomainBatchSessionResumeStateFactory( + ModelFactory[domain_batch.BatchSessionResumeState] +): reference_number: str = "20250625-BS-2C3E6C8000-B675CF5D68-07" aes_key: str = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=" iv: str = "MDEyMzQ1Njc4OWFiY2RlZg==" - access_token: str = "fake-access-token" form_code: domain_session.FormSchema = domain_session.FormSchema.FA3 @classmethod diff --git a/tests/unit/services/test_async_batch.py b/tests/unit/services/test_async_batch.py index 42cd00e..bd2270b 100644 --- a/tests/unit/services/test_async_batch.py +++ b/tests/unit/services/test_async_batch.py @@ -1,3 +1,4 @@ +from collections.abc import Awaitable, Callable from io import BytesIO from zipfile import ZipFile import asyncio @@ -18,13 +19,13 @@ BatchFilePart, BatchInvoice, BatchPreparedPart, - BatchSessionState, + BatchSessionResumeState, PartUploadRequest, PreparedBatch, ) -from ksef2.domain.models.session import FormSchema +from ksef2.domain.models.session import FormSchema, SessionEncryptionMaterial from ksef2.infra.schema.api import spec -from ksef2.services.async_batch import AsyncBatchService +from ksef2.services.async_batch import AsyncBatchService, AsyncBatchSessionOpener from tests.unit.fakes.transport import AsyncFakeTransport from tests.unit.helpers import VALID_PUBLIC_KEY_ID @@ -32,11 +33,17 @@ def _build_service( async_fake_transport: AsyncFakeTransport, *, - open_batch_session, - get_encryption_key=None, + open_batch_session: AsyncBatchSessionOpener, + get_encryption_key: Callable[[], Awaitable[SessionEncryptionMaterial]] + | None = None, ) -> AsyncBatchService: - async def _default_get_encryption_key() -> tuple[bytes, bytes, bytes, str | None]: - return b"k" * 32, b"v" * 16, b"enc-key", VALID_PUBLIC_KEY_ID + async def _default_get_encryption_key() -> SessionEncryptionMaterial: + return SessionEncryptionMaterial( + aes_key=b"k" * 32, + iv=b"v" * 16, + encrypted_key=b"enc-key", + public_key_id=VALID_PUBLIC_KEY_ID, + ) return AsyncBatchService( authed_transport=async_fake_transport, @@ -46,17 +53,38 @@ async def _default_get_encryption_key() -> tuple[bytes, bytes, bytes, str | None ) +async def _unused_open_batch_session( + *, + batch_file: BatchFileInfo, + aes_key: bytes, + iv: bytes, + encrypted_key: bytes, + public_key_id: str | None = None, + form_code: FormSchema = FormSchema.FA3, + offline_mode: bool = False, + prepared_batch: PreparedBatch | None = None, +) -> AsyncBatchSessionClient: + del ( + batch_file, + aes_key, + iv, + encrypted_key, + public_key_id, + form_code, + offline_mode, + prepared_batch, + ) + raise AssertionError("not used") + + class TestAsyncBatchService: def test_prepare_batch_builds_zip_metadata_and_encrypted_part( self, async_fake_transport: AsyncFakeTransport, ) -> None: - async def _open_batch_session(**_): - raise AssertionError("not used") - service = _build_service( async_fake_transport, - open_batch_session=_open_batch_session, + open_batch_session=_unused_open_batch_session, ) invoices = [ BatchInvoice(file_name="invoice-1.xml", content=b"1"), @@ -88,12 +116,9 @@ def test_prepare_batch_rejects_duplicate_file_names( self, async_fake_transport: AsyncFakeTransport, ) -> None: - async def _open_batch_session(**_): - raise AssertionError("not used") - service = _build_service( async_fake_transport, - open_batch_session=_open_batch_session, + open_batch_session=_unused_open_batch_session, ) with pytest.raises(KSeFValidationError, match="must be unique"): @@ -109,14 +134,11 @@ async def _open_batch_session(**_): def test_upload_parts_uses_presigned_urls_without_auth_header( self, async_fake_transport: AsyncFakeTransport, - domain_batch_session_state: BaseFactory[BatchSessionState], + domain_batch_session_state: BaseFactory[BatchSessionResumeState], ) -> None: - async def _open_batch_session(**_): - raise AssertionError("not used") - service = _build_service( async_fake_transport, - open_batch_session=_open_batch_session, + open_batch_session=_unused_open_batch_session, ) state = domain_batch_session_state.build( part_upload_requests=[ @@ -172,7 +194,7 @@ async def _open_batch_session(**_): def test_submit_prepared_batch_uploads_parts_and_closes_session( self, async_fake_transport: AsyncFakeTransport, - domain_batch_session_state: BaseFactory[BatchSessionState], + domain_batch_session_state: BaseFactory[BatchSessionResumeState], ) -> None: state = domain_batch_session_state.build( reference_number="batch-ref", @@ -186,11 +208,30 @@ def test_submit_prepared_batch_uploads_parts_and_closes_session( ], ) - async def _open_batch_session(**kwargs): + async def _open_batch_session( + *, + batch_file: BatchFileInfo, + aes_key: bytes, + iv: bytes, + encrypted_key: bytes, + public_key_id: str | None = None, + form_code: FormSchema = FormSchema.FA3, + offline_mode: bool = False, + prepared_batch: PreparedBatch | None = None, + ) -> AsyncBatchSessionClient: + del ( + batch_file, + aes_key, + iv, + encrypted_key, + public_key_id, + form_code, + offline_mode, + ) return AsyncBatchSessionClient( async_fake_transport, state, - prepared_batch=kwargs.get("prepared_batch"), + prepared_batch=prepared_batch, ) service = _build_service( @@ -238,15 +279,33 @@ async def _open_batch_session(**kwargs): def test_open_session_context_manager_opens_and_closes_session( self, async_fake_transport: AsyncFakeTransport, - domain_batch_session_state: BaseFactory[BatchSessionState], + domain_batch_session_state: BaseFactory[BatchSessionResumeState], ) -> None: state = domain_batch_session_state.build(reference_number="batch-ref") - async def _open_batch_session(**kwargs): + async def _open_batch_session( + *, + batch_file: BatchFileInfo, + aes_key: bytes, + iv: bytes, + encrypted_key: bytes, + public_key_id: str | None = None, + form_code: FormSchema = FormSchema.FA3, + offline_mode: bool = False, + prepared_batch: PreparedBatch | None = None, + ) -> AsyncBatchSessionClient: + assert prepared_batch is not None + assert batch_file == prepared_batch.batch_file + assert aes_key == prepared_batch.encryption.get_aes_key_bytes() + assert iv == prepared_batch.encryption.get_iv_bytes() + assert encrypted_key == prepared_batch.encryption.get_encrypted_key_bytes() + assert public_key_id == prepared_batch.encryption.public_key_id + assert form_code == prepared_batch.form_code + assert offline_mode == prepared_batch.offline_mode return AsyncBatchSessionClient( async_fake_transport, state, - prepared_batch=kwargs.get("prepared_batch"), + prepared_batch=prepared_batch, ) service = _build_service( @@ -295,12 +354,9 @@ def test_wait_for_completion_returns_terminal_success( async_fake_transport: AsyncFakeTransport, inv_session_status_resp: BaseFactory[spec.SessionStatusResponse], ) -> None: - async def _open_batch_session(**_): - raise AssertionError("not used") - service = _build_service( async_fake_transport, - open_batch_session=_open_batch_session, + open_batch_session=_unused_open_batch_session, ) async_fake_transport.enqueue( inv_session_status_resp.build( @@ -329,12 +385,9 @@ def test_wait_for_completion_raises_on_terminal_failure( async_fake_transport: AsyncFakeTransport, inv_session_status_resp: BaseFactory[spec.SessionStatusResponse], ) -> None: - async def _open_batch_session(**_): - raise AssertionError("not used") - service = _build_service( async_fake_transport, - open_batch_session=_open_batch_session, + open_batch_session=_unused_open_batch_session, ) async_fake_transport.enqueue( inv_session_status_resp.build( @@ -356,12 +409,9 @@ def test_wait_for_completion_raises_on_timeout( async_fake_transport: AsyncFakeTransport, inv_session_status_resp: BaseFactory[spec.SessionStatusResponse], ) -> None: - async def _open_batch_session(**_): - raise AssertionError("not used") - service = _build_service( async_fake_transport, - open_batch_session=_open_batch_session, + open_batch_session=_unused_open_batch_session, ) async_fake_transport.enqueue( inv_session_status_resp.build( diff --git a/tests/unit/services/test_batch.py b/tests/unit/services/test_batch.py index 26631ca..0607a10 100644 --- a/tests/unit/services/test_batch.py +++ b/tests/unit/services/test_batch.py @@ -1,3 +1,4 @@ +from collections.abc import Callable from io import BytesIO from zipfile import ZipFile @@ -17,13 +18,13 @@ BatchFilePart, BatchInvoice, BatchPreparedPart, - BatchSessionState, + BatchSessionResumeState, PartUploadRequest, PreparedBatch, ) -from ksef2.domain.models.session import FormSchema +from ksef2.domain.models.session import FormSchema, SessionEncryptionMaterial from ksef2.infra.schema.api import spec -from ksef2.services.batch import BatchService +from ksef2.services.batch import BatchService, BatchSessionOpener from tests.unit.fakes.transport import FakeTransport from tests.unit.helpers import VALID_PUBLIC_KEY_ID @@ -31,18 +32,49 @@ def _build_service( fake_transport: FakeTransport, *, - open_batch_session, - get_encryption_key=None, + open_batch_session: BatchSessionOpener, + get_encryption_key: Callable[[], SessionEncryptionMaterial] | None = None, ) -> BatchService: return BatchService( authed_transport=fake_transport, upload_transport=fake_transport, get_encryption_key=get_encryption_key - or (lambda: (b"k" * 32, b"v" * 16, b"enc-key", VALID_PUBLIC_KEY_ID)), + or ( + lambda: SessionEncryptionMaterial( + aes_key=b"k" * 32, + iv=b"v" * 16, + encrypted_key=b"enc-key", + public_key_id=VALID_PUBLIC_KEY_ID, + ) + ), open_batch_session=open_batch_session, ) +def _unused_open_batch_session( + *, + batch_file: BatchFileInfo, + aes_key: bytes, + iv: bytes, + encrypted_key: bytes, + public_key_id: str | None = None, + form_code: FormSchema = FormSchema.FA3, + offline_mode: bool = False, + prepared_batch: PreparedBatch | None = None, +) -> BatchSessionClient: + del ( + batch_file, + aes_key, + iv, + encrypted_key, + public_key_id, + form_code, + offline_mode, + prepared_batch, + ) + raise AssertionError("not used") + + class TestBatchService: def test_prepare_batch_builds_zip_metadata_and_encrypted_part( self, @@ -50,7 +82,7 @@ def test_prepare_batch_builds_zip_metadata_and_encrypted_part( ) -> None: service = _build_service( fake_transport, - open_batch_session=lambda **_: pytest.fail("not used"), + open_batch_session=_unused_open_batch_session, ) invoices = [ BatchInvoice(file_name="invoice-1.xml", content=b"1"), @@ -89,7 +121,7 @@ def test_prepare_batch_rejects_duplicate_file_names( ) -> None: service = _build_service( fake_transport, - open_batch_session=lambda **_: pytest.fail("not used"), + open_batch_session=_unused_open_batch_session, ) with pytest.raises(KSeFValidationError, match="must be unique"): @@ -103,11 +135,11 @@ def test_prepare_batch_rejects_duplicate_file_names( def test_upload_parts_uses_presigned_urls_without_auth_header( self, fake_transport: FakeTransport, - domain_batch_session_state: BaseFactory[BatchSessionState], + domain_batch_session_state: BaseFactory[BatchSessionResumeState], ) -> None: service = _build_service( fake_transport, - open_batch_session=lambda **_: pytest.fail("not used"), + open_batch_session=_unused_open_batch_session, ) state = domain_batch_session_state.build( part_upload_requests=[ @@ -161,7 +193,7 @@ def test_upload_parts_uses_presigned_urls_without_auth_header( def test_submit_prepared_batch_uploads_parts_and_closes_session( self, fake_transport: FakeTransport, - domain_batch_session_state: BaseFactory[BatchSessionState], + domain_batch_session_state: BaseFactory[BatchSessionResumeState], ) -> None: state = domain_batch_session_state.build( reference_number="batch-ref", @@ -174,13 +206,36 @@ def test_submit_prepared_batch_uploads_parts_and_closes_session( ) ], ) - service = _build_service( - fake_transport, - open_batch_session=lambda **kwargs: BatchSessionClient( + + def _open_batch_session( + *, + batch_file: BatchFileInfo, + aes_key: bytes, + iv: bytes, + encrypted_key: bytes, + public_key_id: str | None = None, + form_code: FormSchema = FormSchema.FA3, + offline_mode: bool = False, + prepared_batch: PreparedBatch | None = None, + ) -> BatchSessionClient: + del ( + batch_file, + aes_key, + iv, + encrypted_key, + public_key_id, + form_code, + offline_mode, + ) + return BatchSessionClient( fake_transport, state, - prepared_batch=kwargs.get("prepared_batch"), - ), + prepared_batch=prepared_batch, + ) + + service = _build_service( + fake_transport, + open_batch_session=_open_batch_session, ) prepared_batch = PreparedBatch( batch_file=BatchFileInfo( @@ -225,7 +280,7 @@ def test_wait_for_completion_returns_terminal_success( ) -> None: service = _build_service( fake_transport, - open_batch_session=lambda **_: pytest.fail("not used"), + open_batch_session=_unused_open_batch_session, ) fake_transport.enqueue( inv_session_status_resp.build( @@ -254,7 +309,7 @@ def test_wait_for_completion_raises_on_terminal_failure( ) -> None: service = _build_service( fake_transport, - open_batch_session=lambda **_: pytest.fail("not used"), + open_batch_session=_unused_open_batch_session, ) fake_transport.enqueue( inv_session_status_resp.build( @@ -276,7 +331,7 @@ def test_wait_for_completion_raises_on_timeout( ) -> None: service = _build_service( fake_transport, - open_batch_session=lambda **_: pytest.fail("not used"), + open_batch_session=_unused_open_batch_session, ) for _ in range(3): fake_transport.enqueue( diff --git a/tests/unit/test_raw.py b/tests/unit/test_raw.py index d18c5fb..60dae01 100644 --- a/tests/unit/test_raw.py +++ b/tests/unit/test_raw.py @@ -13,7 +13,7 @@ from ksef2.core.exceptions import KSeFUnsupportedEnvironmentError from ksef2.core.routes import TokenRoutes from ksef2.core.stores import CertificateStore -from ksef2.domain.models.auth import AuthTokens +from ksef2.domain.models.auth import AuthenticationResumeState, AuthTokens from ksef2.endpoints.auth import AuthEndpoints from ksef2.endpoints.encryption import EncryptionEndpoints from ksef2.endpoints.invoices import InvoicesEndpoints @@ -64,7 +64,9 @@ def test_root_client_binds_tokens_to_authenticated_raw_client( environment=Environment.TEST, http_client=MagicMock(spec=httpx.Client) ) - auth = client.authenticated(domain_auth_tokens.build()) + auth = client.authentication.resume( + AuthenticationResumeState.from_tokens(domain_auth_tokens.build()) + ) assert isinstance(auth, AuthenticatedClient) assert isinstance(auth.raw, RawAuthenticatedClient) diff --git a/tests/unit/test_session_state.py b/tests/unit/test_session_state.py new file mode 100644 index 0000000..9dbbf64 --- /dev/null +++ b/tests/unit/test_session_state.py @@ -0,0 +1,256 @@ +from datetime import UTC, datetime +from typing import cast + +import pytest +from pydantic import SecretStr + +from ksef2.domain.models.auth import ( + AuthenticationResumeState, + AuthTokens, + TokenCredentials, +) +from ksef2.domain.models.batch import ( + BatchSessionResumeState, + PartUploadRequest, +) +from ksef2.domain.models.session import ( + BaseSessionResumeState, + FormSchema, + OnlineSessionResumeState, +) + +AES_KEY = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=" +IV = "MDEyMzQ1Njc4OWFiY2RlZg==" +ACCESS_TOKEN = "secret-access-token" +UPLOAD_URL = "https://example.com/upload/part-1?sig=secret-upload-url" + + +def test_online_session_state_redacts_sensitive_fields_in_default_dump_and_repr() -> ( + None +): + state = OnlineSessionResumeState( + reference_number="20250625-SO-2C3E6C8000-B675CF5D68-07", + aes_key=SecretStr(AES_KEY), + iv=SecretStr(IV), + valid_until=datetime(2026, 1, 1, tzinfo=UTC), + form_code=FormSchema.FA3, + ) + + dumped = state.model_dump() + json_dump = state.model_dump_json() + representation = repr(state) + + assert dumped["reference_number"] == state.reference_number + assert str(cast(object, dumped["aes_key"])) == "**********" + assert str(cast(object, dumped["iv"])) == "**********" + assert "access_token" not in dumped + for secret in [AES_KEY, IV, ACCESS_TOKEN]: + assert secret not in json_dump + assert secret not in representation + + +def test_online_session_state_sensitive_export_round_trips_for_resume() -> None: + state = OnlineSessionResumeState( + reference_number="20250625-SO-2C3E6C8000-B675CF5D68-07", + aes_key=SecretStr(AES_KEY), + iv=SecretStr(IV), + valid_until=datetime(2026, 1, 1, tzinfo=UTC), + form_code=FormSchema.FA3, + ) + + sensitive_dump = state.to_dict() + sensitive_json = state.to_json() + + assert sensitive_dump["aes_key"] == AES_KEY + assert sensitive_dump["iv"] == IV + assert "access_token" not in sensitive_dump + assert sensitive_dump["form_code"] == "FA3" + for secret in [AES_KEY, IV]: + assert secret in sensitive_json + assert ACCESS_TOKEN not in sensitive_json + assert '"form_code":"FA3"' in sensitive_json + + restored = OnlineSessionResumeState.from_json(sensitive_json) + + assert restored.reference_number == state.reference_number + assert restored.form_code == FormSchema.FA3 + assert restored.get_aes_key_bytes() == b"0123456789abcdef0123456789abcdef" + assert restored.get_iv_bytes() == b"0123456789abcdef" + + +def test_batch_session_state_redacts_credentials_and_omits_upload_urls_by_default() -> ( + None +): + state = BatchSessionResumeState( + reference_number="20250625-BS-2C3E6C8000-B675CF5D68-07", + aes_key=SecretStr(AES_KEY), + iv=SecretStr(IV), + form_code=FormSchema.FA3, + part_upload_requests=[ + PartUploadRequest( + ordinal_number=1, + method="PUT", + url=UPLOAD_URL, + headers={"x-ms-blob-type": "BlockBlob"}, + ) + ], + ) + + dumped = state.model_dump() + json_dump = state.model_dump_json() + representation = repr(state) + + assert str(cast(object, dumped["aes_key"])) == "**********" + assert str(cast(object, dumped["iv"])) == "**********" + assert "access_token" not in dumped + assert "part_upload_requests" not in dumped + for secret in [AES_KEY, IV, ACCESS_TOKEN, UPLOAD_URL]: + assert secret not in json_dump + assert secret not in representation + + +def test_batch_session_state_sensitive_export_round_trips_for_resume() -> None: + state = BatchSessionResumeState( + reference_number="20250625-BS-2C3E6C8000-B675CF5D68-07", + aes_key=SecretStr(AES_KEY), + iv=SecretStr(IV), + form_code=FormSchema.FA3, + part_upload_requests=[ + PartUploadRequest( + ordinal_number=1, + method="PUT", + url=UPLOAD_URL, + headers={"x-ms-blob-type": "BlockBlob"}, + ) + ], + ) + + sensitive_dump = state.to_dict() + sensitive_json = state.to_json() + + assert sensitive_dump["aes_key"] == AES_KEY + assert sensitive_dump["iv"] == IV + assert "access_token" not in sensitive_dump + assert sensitive_dump["form_code"] == "FA3" + for secret in [AES_KEY, IV, UPLOAD_URL]: + assert secret in sensitive_json + assert ACCESS_TOKEN not in sensitive_json + assert '"form_code":"FA3"' in sensitive_json + + restored = BatchSessionResumeState.from_json(sensitive_json) + + assert restored.reference_number == state.reference_number + assert restored.form_code == FormSchema.FA3 + assert restored.part_upload_requests[0].url == UPLOAD_URL + assert restored.get_aes_key_bytes() == b"0123456789abcdef0123456789abcdef" + assert restored.get_iv_bytes() == b"0123456789abcdef" + + +def test_deprecated_state_model_imports_warn() -> None: + with pytest.deprecated_call(match="BaseSessionState"): + from ksef2.domain.models.session import BaseSessionState as LegacyBaseState + + with pytest.deprecated_call(match="OnlineSessionState"): + from ksef2.domain.models.session import OnlineSessionState as LegacyOnlineState + + with pytest.deprecated_call(match="BatchSessionState"): + from ksef2.domain.models.batch import BatchSessionState as LegacyBatchState + + base_state = LegacyBaseState + online_state = LegacyOnlineState + batch_state = LegacyBatchState + + assert base_state is BaseSessionResumeState + assert online_state is OnlineSessionResumeState + assert batch_state is BatchSessionResumeState + + +def test_deprecated_state_facade_imports_warn() -> None: + import ksef2.domain.models as domain_models + import ksef2.models as facade_models + + assert "OnlineSessionState" not in domain_models.__all__ + assert "BatchSessionState" not in domain_models.__all__ + assert "OnlineSessionState" not in facade_models.__all__ + assert "BatchSessionState" not in facade_models.__all__ + + with pytest.deprecated_call(match="ksef2.domain.models.OnlineSessionState"): + from ksef2.domain.models import OnlineSessionState as DomainOnlineState + + with pytest.deprecated_call(match="ksef2.models.BatchSessionState"): + from ksef2.models import BatchSessionState as FacadeBatchState + + domain_online_state = DomainOnlineState + facade_batch_state = FacadeBatchState + + assert domain_online_state is OnlineSessionResumeState + assert facade_batch_state is BatchSessionResumeState + + +def test_deprecated_state_export_methods_delegate_to_resume_api() -> None: + state = OnlineSessionResumeState( + reference_number="20250625-SO-2C3E6C8000-B675CF5D68-07", + aes_key=SecretStr(AES_KEY), + iv=SecretStr(IV), + valid_until=datetime(2026, 1, 1, tzinfo=UTC), + form_code=FormSchema.FA3, + ) + + with pytest.deprecated_call(match="dump_state"): + sensitive_dump = state.dump_state() + with pytest.deprecated_call(match="model_dump_sensitive_json"): + sensitive_json = state.model_dump_sensitive_json() + with pytest.deprecated_call(match="from_state"): + restored = OnlineSessionResumeState.from_state(sensitive_dump) + + assert sensitive_dump == state.to_dict(mode="python") + assert sensitive_json == state.to_json() + assert restored == state + + +def test_legacy_session_state_access_token_is_ignored_with_warning() -> None: + state = OnlineSessionResumeState( + reference_number="20250625-SO-2C3E6C8000-B675CF5D68-07", + aes_key=SecretStr(AES_KEY), + iv=SecretStr(IV), + valid_until=datetime(2026, 1, 1, tzinfo=UTC), + form_code=FormSchema.FA3, + ) + legacy_state = state.to_dict() + legacy_state["access_token"] = ACCESS_TOKEN + + with pytest.deprecated_call(match="access_token"): + restored = OnlineSessionResumeState.from_dict(legacy_state) + + assert restored == state + assert "access_token" not in restored.to_dict() + + +def test_authentication_resume_state_round_trips_with_explicit_sensitive_export() -> ( + None +): + auth_tokens = AuthTokens( + access_token=TokenCredentials( + token=ACCESS_TOKEN, + valid_until=datetime(2026, 1, 1, tzinfo=UTC), + ), + refresh_token=TokenCredentials( + token="secret-refresh-token", + valid_until=datetime(2026, 1, 2, tzinfo=UTC), + ), + ) + + state = AuthenticationResumeState.from_tokens(auth_tokens) + dumped = state.model_dump() + json_dump = state.model_dump_json() + sensitive_json = state.to_json() + + assert str(cast(object, dumped["access_token"])) == "**********" + assert str(cast(object, dumped["refresh_token"])) == "**********" + assert ACCESS_TOKEN not in json_dump + assert "secret-refresh-token" not in json_dump + assert ACCESS_TOKEN in sensitive_json + assert "secret-refresh-token" in sensitive_json + assert ( + AuthenticationResumeState.from_json(sensitive_json).to_tokens() == auth_tokens + ) diff --git a/tests/unit/test_shared_refactor_helpers.py b/tests/unit/test_shared_refactor_helpers.py index f372da9..56c27cc 100644 --- a/tests/unit/test_shared_refactor_helpers.py +++ b/tests/unit/test_shared_refactor_helpers.py @@ -10,8 +10,8 @@ from ksef2.clients.batch import BatchSessionClient from ksef2.config import Environment, TimeoutConfig, TransportConfig from ksef2.core.http_config import HttpClientKwargs -from ksef2.domain.models.batch import BatchInvoice -from ksef2.domain.models.session import FormSchema +from ksef2.domain.models.batch import BatchFileInfo, BatchInvoice, PreparedBatch +from ksef2.domain.models.session import FormSchema, SessionEncryptionMaterial from ksef2.endpoints.async_base import AsyncBaseEndpoints from ksef2.endpoints.base import BaseEndpoints from ksef2.services.async_batch import AsyncBatchService @@ -87,26 +87,71 @@ def test_sync_and_async_batch_preparation_share_metadata_logic() -> None: BatchInvoice(file_name="invoice-2.xml", content=b"2"), ] - def _sync_open_batch_session() -> BatchSessionClient: + def _sync_open_batch_session( + *, + batch_file: BatchFileInfo, + aes_key: bytes, + iv: bytes, + encrypted_key: bytes, + public_key_id: str | None = None, + form_code: FormSchema = FormSchema.FA3, + offline_mode: bool = False, + prepared_batch: PreparedBatch | None = None, + ) -> BatchSessionClient: + del ( + batch_file, + aes_key, + iv, + encrypted_key, + public_key_id, + form_code, + offline_mode, + prepared_batch, + ) raise AssertionError("not used") - async def _async_open_batch_session() -> AsyncBatchSessionClient: + async def _async_open_batch_session( + *, + batch_file: BatchFileInfo, + aes_key: bytes, + iv: bytes, + encrypted_key: bytes, + public_key_id: str | None = None, + form_code: FormSchema = FormSchema.FA3, + offline_mode: bool = False, + prepared_batch: PreparedBatch | None = None, + ) -> AsyncBatchSessionClient: + del ( + batch_file, + aes_key, + iv, + encrypted_key, + public_key_id, + form_code, + offline_mode, + prepared_batch, + ) raise AssertionError("not used") sync_service = BatchService( authed_transport=FakeTransport(), upload_transport=FakeTransport(), - get_encryption_key=lambda: ( - b"k" * 32, - b"v" * 16, - b"enc-key", - VALID_PUBLIC_KEY_ID, + get_encryption_key=lambda: SessionEncryptionMaterial( + aes_key=b"k" * 32, + iv=b"v" * 16, + encrypted_key=b"enc-key", + public_key_id=VALID_PUBLIC_KEY_ID, ), open_batch_session=_sync_open_batch_session, ) - async def _get_encryption_key() -> tuple[bytes, bytes, bytes, str | None]: - return b"k" * 32, b"v" * 16, b"enc-key", VALID_PUBLIC_KEY_ID + async def _get_encryption_key() -> SessionEncryptionMaterial: + return SessionEncryptionMaterial( + aes_key=b"k" * 32, + iv=b"v" * 16, + encrypted_key=b"enc-key", + public_key_id=VALID_PUBLIC_KEY_ID, + ) async_service = AsyncBatchService( authed_transport=AsyncFakeTransport(), From 3e4d7b7443e86aa5a4a6978a604ddd46f42ccd5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20Podsiad=C5=82y?= Date: Sun, 28 Jun 2026 22:25:34 +0200 Subject: [PATCH 05/11] feat: simplify invoice filters --- scripts/cli/export_invoices.py | 5 +- .../invoices/download_purchase_invoices.py | 5 +- .../invoices/send_query_export_download.py | 5 +- src/ksef2/domain/models/invoices.py | 165 +++++++++++++++++- src/ksef2/infra/mappers/invoices/requests.py | 3 + tests/unit/mappers/test_invoices.py | 43 +++++ tests/unit/test_domain_model_validation.py | 152 ++++++++++++++++ 7 files changed, 358 insertions(+), 20 deletions(-) create mode 100644 tests/unit/test_domain_model_validation.py diff --git a/scripts/cli/export_invoices.py b/scripts/cli/export_invoices.py index 93471df..5757cd7 100644 --- a/scripts/cli/export_invoices.py +++ b/scripts/cli/export_invoices.py @@ -145,12 +145,9 @@ def main(argv: list[str] | None = None) -> None: print(f"Authentication failed: {exc}", file=sys.stderr) sys.exit(1) - query_filters = InvoicesFilter( - role="buyer", - date_type="issue_date", + query_filters = InvoicesFilter.for_buyer( date_from=datetime.now(tz=timezone.utc) - timedelta(days=args.days), date_to=datetime.now(tz=timezone.utc), - amount_type="brutto", ) try: diff --git a/scripts/examples/invoices/download_purchase_invoices.py b/scripts/examples/invoices/download_purchase_invoices.py index cab654f..2af454d 100644 --- a/scripts/examples/invoices/download_purchase_invoices.py +++ b/scripts/examples/invoices/download_purchase_invoices.py @@ -65,12 +65,9 @@ def download_for_nip(client: Client, nip: str, config: ExampleConfig) -> None: print(f"[{nip}] Scheduling export of purchase invoices...") export = auth.invoices.schedule_export( - filters=InvoicesFilter( - role="buyer", - date_type="issue_date", + filters=InvoicesFilter.for_buyer( date_from=config.date_from, date_to=config.date_to, - amount_type="brutto", ) ) diff --git a/scripts/examples/invoices/send_query_export_download.py b/scripts/examples/invoices/send_query_export_download.py index c6af4db..fff2b06 100644 --- a/scripts/examples/invoices/send_query_export_download.py +++ b/scripts/examples/invoices/send_query_export_download.py @@ -56,12 +56,9 @@ def run(config: ExampleConfig) -> None: print(f"Invoice processed as KSeF number: {status.ksef_number}") export = auth.invoices.schedule_export( - filters=InvoicesFilter( - role="seller", - date_type="issue_date", + filters=InvoicesFilter.for_seller( date_from=datetime.now(tz=timezone.utc) - timedelta(days=1), date_to=datetime.now(tz=timezone.utc), - amount_type="brutto", ) ) print(f"Export scheduled: {export.reference_number}") diff --git a/src/ksef2/domain/models/invoices.py b/src/ksef2/domain/models/invoices.py index ac39bd9..6a6d841 100644 --- a/src/ksef2/domain/models/invoices.py +++ b/src/ksef2/domain/models/invoices.py @@ -3,9 +3,9 @@ from dataclasses import dataclass, field from datetime import date, datetime from enum import StrEnum -from typing import Literal +from typing import Literal, Self -from pydantic import field_validator +from pydantic import TypeAdapter, field_validator, model_validator from ksef2.domain.models.base import KSeFBaseModel from ksef2.domain.models.compression import ( @@ -92,6 +92,7 @@ class ThirdSubjectIdentifierTypeEnum(StrEnum): _INVOICING_MODE_FROM_SPEC: dict[InvoicingModeSpecValue, InvoicingMode] = { value: key for key, value in _INVOICING_MODE_TO_SPEC.items() } +_DATETIME_ADAPTER = TypeAdapter(datetime) def normalize_sort_order(value: SortOrder | SortOrderEnum | str) -> SortOrder: @@ -335,12 +336,14 @@ class ExportHandle: ### Public API ### -class AmountMixin(KSeFBaseModel): - """Reusable amount-range filter fields.""" +def _parse_filter_datetime(value: datetime | str) -> datetime | None: + if isinstance(value, datetime): + return value - amount_type: Literal["brutto", "netto", "vat"] - amount_min: float | None = None - amount_max: float | None = None + try: + return _DATETIME_ADAPTER.validate_python(value) + except ValueError: + return None class InvoicesFilter(KSeFBaseModel): @@ -357,7 +360,7 @@ class InvoicesFilter(KSeFBaseModel): # currency and amounts currency_codes: list[CurrencyCodes] | None = None - amount_type: Literal["brutto", "netto", "vat"] + amount_type: Literal["brutto", "netto", "vat"] | None = None amount_min: float | None = None amount_max: float | None = None @@ -385,6 +388,152 @@ def _normalize_invoicing_mode(cls, value: object) -> object: return normalize_invoicing_mode(value) return value + @classmethod + def for_buyer( + cls, + *, + date_from: datetime | str, + date_to: datetime | str | None = None, + date_type: Literal[ + "issue_date", "invoicing_date", "permanent_storage" + ] = "issue_date", + restrict_to_permanent_storage_hwm_date: bool | None = None, + currency_codes: list[CurrencyCodes] | None = None, + amount_type: Literal["brutto", "netto", "vat"] | None = None, + amount_min: float | None = None, + amount_max: float | None = None, + seller_nip: str | None = None, + buyer_nip: str | None = None, + buyer_vat_ue: str | None = None, + buyer_other_id: str | None = None, + invoice_number: str | None = None, + ksef_number: str | None = None, + invoice_schema: FormSchema | None = None, + invoice_types: list[KsefInvoiceTypes] | None = None, + has_attachment: bool | None = None, + invoicing_mode: InvoicingMode | None = None, + is_self_invoicing: bool | None = None, + ) -> Self: + """Build a filter for invoices where the authenticated subject is buyer.""" + effective_date_to = date_to if date_to is not None else datetime.now() + return cls( + role="buyer", + date_type=date_type, + date_from=date_from, + date_to=effective_date_to, + restrict_to_permanent_storage_hwm_date=( + restrict_to_permanent_storage_hwm_date + ), + currency_codes=currency_codes, + amount_type=amount_type, + amount_min=amount_min, + amount_max=amount_max, + seller_nip=seller_nip, + buyer_nip=buyer_nip, + buyer_vat_ue=buyer_vat_ue, + buyer_other_id=buyer_other_id, + invoice_number=invoice_number, + ksef_number=ksef_number, + invoice_schema=invoice_schema, + invoice_types=invoice_types, + has_attachment=has_attachment, + invoicing_mode=invoicing_mode, + is_self_invoicing=is_self_invoicing, + ) + + @classmethod + def for_seller( + cls, + *, + date_from: datetime | str, + date_to: datetime | str | None = None, + date_type: Literal[ + "issue_date", "invoicing_date", "permanent_storage" + ] = "issue_date", + restrict_to_permanent_storage_hwm_date: bool | None = None, + currency_codes: list[CurrencyCodes] | None = None, + amount_type: Literal["brutto", "netto", "vat"] | None = None, + amount_min: float | None = None, + amount_max: float | None = None, + seller_nip: str | None = None, + buyer_nip: str | None = None, + buyer_vat_ue: str | None = None, + buyer_other_id: str | None = None, + invoice_number: str | None = None, + ksef_number: str | None = None, + invoice_schema: FormSchema | None = None, + invoice_types: list[KsefInvoiceTypes] | None = None, + has_attachment: bool | None = None, + invoicing_mode: InvoicingMode | None = None, + is_self_invoicing: bool | None = None, + ) -> Self: + """Build a filter for invoices where the authenticated subject is seller.""" + effective_date_to = date_to if date_to is not None else datetime.now() + return cls( + role="seller", + date_type=date_type, + date_from=date_from, + date_to=effective_date_to, + restrict_to_permanent_storage_hwm_date=( + restrict_to_permanent_storage_hwm_date + ), + currency_codes=currency_codes, + amount_type=amount_type, + amount_min=amount_min, + amount_max=amount_max, + seller_nip=seller_nip, + buyer_nip=buyer_nip, + buyer_vat_ue=buyer_vat_ue, + buyer_other_id=buyer_other_id, + invoice_number=invoice_number, + ksef_number=ksef_number, + invoice_schema=invoice_schema, + invoice_types=invoice_types, + has_attachment=has_attachment, + invoicing_mode=invoicing_mode, + is_self_invoicing=is_self_invoicing, + ) + + @model_validator(mode="after") + def _validate_filter_shape(self) -> Self: + if ( + self.amount_min is not None or self.amount_max is not None + ) and self.amount_type is None: + raise ValueError( + "amount_type must be specified when amount_min or amount_max is used." + ) + + if ( + self.amount_min is not None + and self.amount_max is not None + and self.amount_min > self.amount_max + ): + raise ValueError("amount_min must be less than or equal to amount_max.") + + buyer_identifiers = [ + field_name + for field_name in ("buyer_nip", "buyer_vat_ue", "buyer_other_id") + if getattr(self, field_name) + ] + if len(buyer_identifiers) > 1: + joined = ", ".join(buyer_identifiers) + raise ValueError(f"Only one buyer identifier can be specified: {joined}.") + + date_from = _parse_filter_datetime(self.date_from) + date_to = _parse_filter_datetime(self.date_to) + if date_from is None or date_to is None: + return self + + try: + date_from_is_after_date_to = date_from > date_to + except TypeError: + date_from_is_after_date_to = False + + if date_from_is_after_date_to: + raise ValueError("date_from must be less than or equal to date_to.") + + return self + class ExportInvoicesPayload(KSeFBaseModel): """Payload used to schedule an encrypted invoice export.""" diff --git a/src/ksef2/infra/mappers/invoices/requests.py b/src/ksef2/infra/mappers/invoices/requests.py index ca3f915..697a3f8 100644 --- a/src/ksef2/infra/mappers/invoices/requests.py +++ b/src/ksef2/infra/mappers/invoices/requests.py @@ -132,6 +132,9 @@ def _map_amount( if request.amount_min is None and request.amount_max is None: return None + if request.amount_type is None: + raise ValueError("amount_type must be specified when amount range is used") + return spec.InvoiceQueryAmount( type=_map_amount_type(request.amount_type), **{"from": request.amount_min}, diff --git a/tests/unit/mappers/test_invoices.py b/tests/unit/mappers/test_invoices.py index 703f0f3..d9c2da3 100644 --- a/tests/unit/mappers/test_invoices.py +++ b/tests/unit/mappers/test_invoices.py @@ -18,6 +18,49 @@ def test_to_spec_legacy_invoices_filter(self, inv_export_filters) -> None: assert output.dateRange.dateType == spec.InvoiceQueryDateType.Issue assert output.formType == spec.InvoiceQueryFormType.FA_RR + def test_to_spec_omits_amount_filter_without_amount_range(self) -> None: + request = domain_invoices.InvoicesFilter.for_seller( + date_from="2026-01-01T00:00:00", + date_to="2026-01-02T00:00:00", + ) + + output = to_spec(request) + + assert isinstance(output, spec.InvoiceQueryFilters) + assert output.amount is None + + def test_to_spec_maps_amount_filter_when_range_is_present(self) -> None: + request = domain_invoices.InvoicesFilter.for_seller( + date_from="2026-01-01T00:00:00", + date_to="2026-01-02T00:00:00", + amount_type="netto", + amount_min=100.0, + amount_max=200.0, + ) + + output = to_spec(request) + + assert isinstance(output, spec.InvoiceQueryFilters) + assert output.amount is not None + assert output.amount.type == spec.AmountType.Netto + assert output.amount.from_ == 100.0 + assert output.amount.to == 200.0 + + def test_to_spec_maps_buyer_identifier_from_filter_constructor(self) -> None: + request = domain_invoices.InvoicesFilter.for_buyer( + date_from="2026-01-01T00:00:00", + date_to="2026-01-02T00:00:00", + buyer_vat_ue="PL1234567890", + ) + + output = to_spec(request) + + assert isinstance(output, spec.InvoiceQueryFilters) + assert output.subjectType == spec.InvoiceQuerySubjectType.Subject2 + assert output.buyerIdentifier is not None + assert output.buyerIdentifier.type == spec.BuyerIdentifierType.VatUe + assert output.buyerIdentifier.value == "PL1234567890" + def test_to_export_request(self, inv_export_filters) -> None: request = ExportInvoicesPayload( filter=inv_export_filters.build(), diff --git a/tests/unit/test_domain_model_validation.py b/tests/unit/test_domain_model_validation.py new file mode 100644 index 0000000..e6cbb76 --- /dev/null +++ b/tests/unit/test_domain_model_validation.py @@ -0,0 +1,152 @@ +from datetime import datetime + +import pytest +from pydantic import ValidationError + +from ksef2.domain.models.batch import BatchFileInfo, BatchFilePart +from ksef2.domain.models.invoices import InvoicesFilter + + +def _batch_part( + *, + ordinal_number: int = 1, + file_size: int = 1, +) -> BatchFilePart: + return BatchFilePart( + ordinal_number=ordinal_number, + file_size=file_size, + file_hash="hash", + ) + + +def _invoice_filter(**overrides: object) -> InvoicesFilter: + data: dict[str, object] = { + "role": "seller", + "date_type": "issue_date", + "date_from": "2026-01-01T00:00:00", + "date_to": "2026-01-02T00:00:00", + } + data.update(overrides) + return InvoicesFilter.model_validate(data) + + +def test_batch_file_info_rejects_file_larger_than_5gb() -> None: + with pytest.raises(ValidationError, match="less than or equal to 5000000000"): + BatchFileInfo( + file_size=5_000_000_001, + file_hash="hash", + parts=[_batch_part()], + ) + + +def test_batch_file_info_rejects_more_than_50_parts() -> None: + parts = [ + _batch_part(ordinal_number=ordinal_number) for ordinal_number in range(1, 52) + ] + + with pytest.raises(ValidationError, match="at most 50"): + BatchFileInfo(file_size=1, file_hash="hash", parts=parts) + + +def test_batch_file_part_rejects_negative_size() -> None: + with pytest.raises(ValidationError, match="greater than or equal to 0"): + _batch_part(file_size=-1) + + +def test_batch_file_part_rejects_encrypted_size_above_represented_limit() -> None: + with pytest.raises(ValidationError, match="less than or equal to 100000016"): + _batch_part(file_size=100_000_017) + + +def test_batch_file_part_rejects_zero_ordinal_number() -> None: + with pytest.raises(ValidationError, match="greater than or equal to 1"): + _batch_part(ordinal_number=0) + + +def test_invoices_filter_rejects_amount_min_greater_than_amount_max() -> None: + with pytest.raises( + ValidationError, + match="amount_min must be less than or equal to amount_max", + ): + _invoice_filter(amount_type="brutto", amount_min=100.0, amount_max=10.0) + + +def test_invoices_filter_allows_missing_amount_type_without_amount_range() -> None: + filters = _invoice_filter() + + assert filters.amount_type is None + + +def test_invoices_filter_requires_amount_type_with_amount_range() -> None: + with pytest.raises( + ValidationError, + match="amount_type must be specified when amount_min or amount_max is used", + ): + _invoice_filter(amount_min=10.0) + + +def test_invoices_filter_rejects_multiple_buyer_identifiers() -> None: + with pytest.raises( + ValidationError, + match="Only one buyer identifier can be specified: buyer_nip, buyer_vat_ue", + ): + _invoice_filter(buyer_nip="1234567890", buyer_vat_ue="PL1234567890") + + +def test_invoices_filter_buyer_constructor_sets_role_and_identifier() -> None: + filters = InvoicesFilter.for_buyer( + date_from="2026-01-01T00:00:00", + date_to="2026-01-02T00:00:00", + buyer_nip="1234567890", + invoice_types=["vat"], + ) + + assert filters.role == "buyer" + assert filters.date_type == "issue_date" + assert filters.buyer_nip == "1234567890" + assert filters.invoice_types == ["vat"] + + +def test_invoices_filter_seller_constructor_sets_role_and_counterparty() -> None: + filters = InvoicesFilter.for_seller( + date_from="2026-01-01T00:00:00", + date_to="2026-01-02T00:00:00", + seller_nip="1234567890", + buyer_other_id="client-1", + ) + + assert filters.role == "seller" + assert filters.seller_nip == "1234567890" + assert filters.buyer_other_id == "client-1" + + +def test_invoices_filter_rejects_reversed_parseable_date_strings() -> None: + with pytest.raises( + ValidationError, + match="date_from must be less than or equal to date_to", + ): + _invoice_filter( + date_from="2026-02-01T00:00:00", + date_to="2026-01-01T00:00:00", + ) + + +def test_invoices_filter_rejects_reversed_datetime_values() -> None: + with pytest.raises( + ValidationError, + match="date_from must be less than or equal to date_to", + ): + _invoice_filter( + date_from=datetime(2026, 2, 1), + date_to=datetime(2026, 1, 1), + ) + + +def test_invoices_filter_preserves_valid_date_string_fields() -> None: + filters = _invoice_filter( + date_from="2026-01-01T00:00:00", + date_to="2026-01-02T00:00:00", + ) + + assert filters.date_from == "2026-01-01T00:00:00" + assert filters.date_to == "2026-01-02T00:00:00" From 473c430e89589e2871fe71e6519978a70d251381 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20Podsiad=C5=82y?= Date: Sun, 28 Jun 2026 22:26:31 +0200 Subject: [PATCH 06/11] feat: add FA3 invoice builder --- .../invoices/build_fa3_invoice_builder.py | 107 +++ .../invoices/build_fa3_invoice_sample_1.py | 153 +++ scripts/quickstart.py | 78 ++ src/ksef2/domain/models/fa3/__init__.py | 5 +- src/ksef2/domain/models/fa3/invoice.py | 44 + src/ksef2/domain/models/fa3/references.py | 11 + src/ksef2/domain/protocols.py | 39 + src/ksef2/fa3/__init__.py | 42 + src/ksef2/services/builders/__init__.py | 15 + src/ksef2/services/builders/fa3/__init__.py | 1 + src/ksef2/services/builders/fa3/attachment.py | 439 +++++++++ .../services/builders/fa3/body/__init__.py | 23 + .../services/builders/fa3/body/advance.py | 99 ++ src/ksef2/services/builders/fa3/body/base.py | 316 +++++++ .../services/builders/fa3/body/correction.py | 101 ++ .../builders/fa3/body/correction_advance.py | 112 +++ .../fa3/body/correction_settlement.py | 123 +++ .../services/builders/fa3/body/settlement.py | 110 +++ .../services/builders/fa3/body/simplified.py | 101 ++ .../services/builders/fa3/body/standard.py | 101 ++ src/ksef2/services/builders/fa3/footer.py | 157 ++++ src/ksef2/services/builders/fa3/metadata.py | 51 + src/ksef2/services/builders/fa3/root.py | 871 +++++++++++++++++ .../services/builders/fa3/sub/__init__.py | 1 + .../services/builders/fa3/sub/advance.py | 239 +++++ .../services/builders/fa3/sub/annotations.py | 479 ++++++++++ .../services/builders/fa3/sub/correction.py | 433 +++++++++ src/ksef2/services/builders/fa3/sub/order.py | 353 +++++++ .../services/builders/fa3/sub/payment.py | 563 +++++++++++ src/ksef2/services/builders/fa3/sub/rows.py | 455 +++++++++ .../services/builders/fa3/sub/settlement.py | 227 +++++ .../services/builders/fa3/sub/transaction.py | 432 +++++++++ tests/integration/builders/__init__.py | 0 tests/integration/builders/fa3/__init__.py | 0 .../builders/fa3/test_advance_samples.py | 889 ++++++++++++++++++ .../builders/fa3/test_correction_samples.py | 628 +++++++++++++ .../builders/fa3/test_settlement_samples.py | 764 +++++++++++++++ .../builders/fa3/test_simplified_samples.py | 163 ++++ .../builders/fa3/test_standard_invoice.py | 126 +++ .../builders/fa3/test_vat_samples.py | 580 ++++++++++++ tests/integration/builders/helpers.py | 13 + tests/unit/test_fa3_builder_metadata.py | 150 +++ tests/unit/test_fa3_drafts.py | 166 ++++ tests/unit/test_fa3_public_api.py | 112 +++ 44 files changed, 9871 insertions(+), 1 deletion(-) create mode 100644 scripts/examples/invoices/build_fa3_invoice_builder.py create mode 100644 scripts/examples/invoices/build_fa3_invoice_sample_1.py create mode 100644 scripts/quickstart.py create mode 100644 src/ksef2/domain/protocols.py create mode 100644 src/ksef2/fa3/__init__.py create mode 100644 src/ksef2/services/builders/__init__.py create mode 100644 src/ksef2/services/builders/fa3/__init__.py create mode 100644 src/ksef2/services/builders/fa3/attachment.py create mode 100644 src/ksef2/services/builders/fa3/body/__init__.py create mode 100644 src/ksef2/services/builders/fa3/body/advance.py create mode 100644 src/ksef2/services/builders/fa3/body/base.py create mode 100644 src/ksef2/services/builders/fa3/body/correction.py create mode 100644 src/ksef2/services/builders/fa3/body/correction_advance.py create mode 100644 src/ksef2/services/builders/fa3/body/correction_settlement.py create mode 100644 src/ksef2/services/builders/fa3/body/settlement.py create mode 100644 src/ksef2/services/builders/fa3/body/simplified.py create mode 100644 src/ksef2/services/builders/fa3/body/standard.py create mode 100644 src/ksef2/services/builders/fa3/footer.py create mode 100644 src/ksef2/services/builders/fa3/metadata.py create mode 100644 src/ksef2/services/builders/fa3/root.py create mode 100644 src/ksef2/services/builders/fa3/sub/__init__.py create mode 100644 src/ksef2/services/builders/fa3/sub/advance.py create mode 100644 src/ksef2/services/builders/fa3/sub/annotations.py create mode 100644 src/ksef2/services/builders/fa3/sub/correction.py create mode 100644 src/ksef2/services/builders/fa3/sub/order.py create mode 100644 src/ksef2/services/builders/fa3/sub/payment.py create mode 100644 src/ksef2/services/builders/fa3/sub/rows.py create mode 100644 src/ksef2/services/builders/fa3/sub/settlement.py create mode 100644 src/ksef2/services/builders/fa3/sub/transaction.py create mode 100644 tests/integration/builders/__init__.py create mode 100644 tests/integration/builders/fa3/__init__.py create mode 100644 tests/integration/builders/fa3/test_advance_samples.py create mode 100644 tests/integration/builders/fa3/test_correction_samples.py create mode 100644 tests/integration/builders/fa3/test_settlement_samples.py create mode 100644 tests/integration/builders/fa3/test_simplified_samples.py create mode 100644 tests/integration/builders/fa3/test_standard_invoice.py create mode 100644 tests/integration/builders/fa3/test_vat_samples.py create mode 100644 tests/integration/builders/helpers.py create mode 100644 tests/unit/test_fa3_builder_metadata.py create mode 100644 tests/unit/test_fa3_drafts.py create mode 100644 tests/unit/test_fa3_public_api.py diff --git a/scripts/examples/invoices/build_fa3_invoice_builder.py b/scripts/examples/invoices/build_fa3_invoice_builder.py new file mode 100644 index 0000000..6ab5351 --- /dev/null +++ b/scripts/examples/invoices/build_fa3_invoice_builder.py @@ -0,0 +1,107 @@ +from dataclasses import dataclass +from datetime import date +from decimal import Decimal +from pathlib import Path + +from lxml import etree + +from ksef2.fa3 import FA3InvoiceBuilder, VatRate +from ksef2.services.renderers import InvoicePDFExporter + +_MARKER = "pyproject.toml" + + +def repo_root() -> Path: + for parent in (Path(__file__).resolve(), *Path(__file__).resolve().parents): + if (parent / _MARKER).exists(): + return parent + raise FileNotFoundError("Could not find repo root") + + +@dataclass +class ExampleConfig: + output_path: Path = repo_root() / "output" / "fa3_invoice.xml" + pdf_output_path: Path = repo_root() / "output" / "fa3_invoice.pdf" + schema_path: Path = repo_root() / "schemas" / "FA3" / "schemat.xsd" + + +def build_invoice_builder() -> FA3InvoiceBuilder: + builder = FA3InvoiceBuilder() + return ( + builder.header(system_info="ksef2 example builder") + .seller( + name="ACME S.A.", + tax_id="1234567890", + country_code="PL", + address_line_1="ul. Przykładowa 123", + address_line_2="Warszawa", + ) + .buyer( + name="XYZ GmbH", + country_code="DE", + address_line_1="Unter den Linden 1", + address_line_2="10115 Berlin", + ) + .standard() + .issue_place("Warszawa") + .issue_date(date(2026, 3, 29)) + .invoice_number("FV/2026/03/0001") + .billing_period( + period_start=date(2026, 3, 1), + period_end=date(2026, 3, 31), + ) + .rows() + .add_line( + name="Consulting service", + supply_date=date(2026, 3, 29), + unit_of_measure="h", + quantity=Decimal("10"), + unit_price_net=Decimal("100.00"), + vat_rate=VatRate.VAT_23, + ) + .done() + .payment() + .via("bank_transfer") + .due_on(date(2026, 4, 12)) + .bank_account("PL10101010101010101010101010") + .done() + .annotations() + .split_payment() + .done() + .done() + ) + + +def validate_invoice_xml(xml_path: Path, schema_path: Path) -> None: + schema = etree.XMLSchema(etree.parse(str(schema_path))) + xml_doc = etree.parse(str(xml_path)) + if schema.validate(xml_doc): + return + + raise ValueError(f"Generated FA(3) XML failed XSD validation:\n{schema.error_log}") + + +def export_invoice_pdf(xml_path: Path, pdf_output_path: Path) -> Path: + pdf_exporter = InvoicePDFExporter() + return pdf_exporter.export_to_file(xml_path, pdf_output_path) + + +def run(config: ExampleConfig) -> tuple[Path, Path]: + invoice_xml = build_invoice_builder().to_xml() + config.output_path.parent.mkdir(parents=True, exist_ok=True) + _ = config.output_path.write_text(invoice_xml, encoding="utf-8") + validate_invoice_xml(config.output_path, config.schema_path) + exported_pdf_path = export_invoice_pdf(config.output_path, config.pdf_output_path) + print(f"Saved FA(3) invoice XML to: {config.output_path}") + print(f"Validated FA(3) invoice XML against: {config.schema_path}") + print(f"Saved FA(3) invoice PDF to: {exported_pdf_path}") + return config.output_path, exported_pdf_path + + +def main() -> int: + _ = run(ExampleConfig()) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/examples/invoices/build_fa3_invoice_sample_1.py b/scripts/examples/invoices/build_fa3_invoice_sample_1.py new file mode 100644 index 0000000..852d863 --- /dev/null +++ b/scripts/examples/invoices/build_fa3_invoice_sample_1.py @@ -0,0 +1,153 @@ +from dataclasses import dataclass +from datetime import date, datetime, timezone +from decimal import Decimal +from pathlib import Path + +from lxml import etree + +from ksef2.fa3 import FA3InvoiceBuilder, InvoiceSummaryOverrides, VatRate + +_MARKER = "pyproject.toml" + + +def repo_root() -> Path: + for parent in (Path(__file__).resolve(), *Path(__file__).resolve().parents): + if (parent / _MARKER).exists(): + return parent + raise FileNotFoundError("Could not find repo root") + + +@dataclass +class ExampleConfig: + output_path: Path = repo_root() / "output" / "fa3_przyklad_1_like.xml" + schema_path: Path = repo_root() / "schemas" / "FA3" / "schemat.xsd" + sample_path: Path = ( + repo_root() / "schemas" / "FA3" / "samples" / "FA_3_Przykład_1.xml" + ) + + +def build_invoice_xml() -> str: + builder = FA3InvoiceBuilder() + _ = ( + builder.header( + generation_timestamp=datetime(2026, 2, 1, 0, 0, 0, tzinfo=timezone.utc), + system_info="SamploFaktur", + ) + .seller( + name="ABC AGD sp. z o. o.", + tax_id="9999999999", + country_code="PL", + address_line_1="ul. Kwiatowa 1 m. 2", + address_line_2="00-001 Warszawa", + email="abc@abc.pl", + phone="667444555", + ) + .buyer( + name="F.H.U. Jan Kowalski", + tax_id="1111111111", + country_code="PL", + address_line_1="ul. Polna 1", + address_line_2="00-001 Warszawa", + customer_number="fdfd778343", + email="jan@kowalski.pl", + phone="555777999", + ) + ) + + _ = ( + builder.footer() + .add_information("Kapiał zakładowy 5 000 000") + .add_registry( + krs="0000099999", + regon="999999999", + bdo="000099999", + ) + .done() + ) + + _ = ( + builder.standard() + .issue_date(date(2026, 2, 15)) + .issue_place("Warszawa") + .invoice_number("FV2026/02/150") + .date_of_supply(date(2026, 1, 27)) + .mark_fp() + .summary_overrides( + InvoiceSummaryOverrides( + base_rate_net_total=Decimal("1666.66"), + base_rate_vat_total=Decimal("383.33"), + second_reduced_rate_net_total=Decimal("0.95"), + second_reduced_rate_vat_total=Decimal("0.05"), + total_gross=Decimal("2051.00"), + ) + ) + .add_description( + key="preferowane godziny dowozu", + value="dni robocze 17:00 - 20:00", + ) + .rows() + .add_line( + name="lodówka Zimnotech mk1", + quantity=Decimal("1"), + unit_of_measure="szt.", + unit_price_net=Decimal("1626.01"), + vat_rate=VatRate.VAT_23, + unique_id="aaaa111133339990", + ) + .add_line( + name="wniesienie sprzętu", + quantity=Decimal("1"), + unit_of_measure="szt.", + unit_price_net=Decimal("40.65"), + vat_rate=VatRate.VAT_23, + unique_id="aaaa111133339991", + ) + .add_line( + name="promocja lodówka pełna mleka", + quantity=Decimal("1"), + unit_of_measure="szt.", + unit_price_net=Decimal("0.95"), + vat_rate=VatRate.VAT_5, + unique_id="aaaa111133339992", + ) + .done() + .payment() + .via("bank_transfer") + .already_paid(date(2026, 1, 27)) + .done() + .transaction() + .add_order(order_date=date(2026, 1, 26), order_number="4354343") + .done() + .done() + ) + + return builder.to_xml() + + +def validate_invoice_xml(xml_path: Path, schema_path: Path) -> None: + schema = etree.XMLSchema(etree.parse(str(schema_path))) + xml_doc = etree.parse(str(xml_path)) + if schema.validate(xml_doc): + return + + raise ValueError(f"Generated FA(3) XML failed XSD validation:\n{schema.error_log}") + + +def run(config: ExampleConfig) -> Path: + invoice_xml = build_invoice_xml() + config.output_path.parent.mkdir(parents=True, exist_ok=True) + _ = config.output_path.write_text(invoice_xml, encoding="utf-8") + validate_invoice_xml(config.output_path, config.schema_path) + print(f"Saved FA(3) XML to: {config.output_path}") + print(f"Validated generated XML against: {config.schema_path}") + print(f"Reference sample: {config.sample_path}") + return config.output_path + + +def main() -> int: + _ = run(ExampleConfig()) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/quickstart.py b/scripts/quickstart.py new file mode 100644 index 0000000..0abab2c --- /dev/null +++ b/scripts/quickstart.py @@ -0,0 +1,78 @@ +from datetime import date, datetime, timezone +from decimal import Decimal +from pathlib import Path + +from ksef2 import Client, Environment, FormSchema +from ksef2.fa3 import FA3InvoiceBuilder, VatRate + + +SELLER_NIP = "5261040828" +DOWNLOADS = Path("downloads") + +invoice_number = f"QS/{datetime.now(timezone.utc):%Y%m%d%H%M%S}" +invoice_xml = ( + FA3InvoiceBuilder() + .header(system_info="ksef2 quickstart") + .seller( + name="Demo Seller Sp. z o.o.", + tax_id=SELLER_NIP, + country_code="PL", + address_line_1="Prosta 1", + address_line_2="00-001 Warszawa", + ) + .buyer( + name="Demo Buyer Sp. z o.o.", + country_code="PL", + address_line_1="Kwiatowa 2", + address_line_2="00-002 Warszawa", + ) + .standard() + .issue_date(date.today()) + .issue_place("Warszawa") + .invoice_number(invoice_number) + .rows() + .add_line( + name="Consulting service", + quantity=Decimal("1"), + unit_price_net=Decimal("100.00"), + vat_rate=VatRate.VAT_23, + ) + .done() + .done() + .to_xml() + .encode("utf-8") +) + +DOWNLOADS.mkdir(exist_ok=True) +(DOWNLOADS / "generated-invoice.xml").write_bytes(invoice_xml) + +with Client(Environment.TEST) as client: + auth = client.authentication.with_test_certificate(nip=SELLER_NIP) + + with auth.online_session(form_code=FormSchema.FA3) as session: + sent = session.send_invoice(invoice_xml=invoice_xml) + print("Sent invoice:") + print(sent.model_dump_json(indent=2)) + + status = session.wait_for_invoice_ready( + invoice_reference_number=sent.reference_number, + timeout=120.0, + ) + print("Invoice status:") + print(status.model_dump_json(indent=2)) + + upo_xml = session.get_invoice_upo_by_reference( + invoice_reference_number=sent.reference_number, + ) + (DOWNLOADS / "upo.xml").write_bytes(upo_xml) + print("Saved downloads/upo.xml") + + if status.ksef_number is None: + raise RuntimeError("KSeF did not assign an invoice number.") + + downloaded_xml = auth.invoices.wait_for_invoice_download( + ksef_number=status.ksef_number, + timeout=120.0, + ) + (DOWNLOADS / "processed-invoice.xml").write_bytes(downloaded_xml) + print("Saved downloads/processed-invoice.xml") diff --git a/src/ksef2/domain/models/fa3/__init__.py b/src/ksef2/domain/models/fa3/__init__.py index 7d24a76..60c9622 100644 --- a/src/ksef2/domain/models/fa3/__init__.py +++ b/src/ksef2/domain/models/fa3/__init__.py @@ -5,7 +5,7 @@ AttachmentTable, DataBlock, ) -from ksef2.domain.models.fa3.invoice import KsefInvoice +from ksef2.domain.models.fa3.invoice import KsefInvoice, KsefInvoiceDraft from ksef2.domain.models.fa3.footer import ( FooterRegistry, InvoiceFooter, @@ -52,6 +52,7 @@ from ksef2.domain.models.fa3.references import ( AdvanceInvoiceReference, CorrectedInvoiceReference, + DraftIntent, ) __all__ = [ @@ -68,6 +69,7 @@ "CorrectedSellerEntity", "CorrectedInvoiceReference", "DataBlock", + "DraftIntent", "FooterRegistry", "InvoiceAddress", "InvoiceEntity", @@ -82,6 +84,7 @@ "InvoiceHeader", "KsefInvoiceBody", "KsefInvoice", + "KsefInvoiceDraft", "MarginProcedure", "NewTransportMeansItem", "NewTransportSupply", diff --git a/src/ksef2/domain/models/fa3/invoice.py b/src/ksef2/domain/models/fa3/invoice.py index 046a3c4..caccac9 100644 --- a/src/ksef2/domain/models/fa3/invoice.py +++ b/src/ksef2/domain/models/fa3/invoice.py @@ -15,6 +15,50 @@ from ksef2.domain.models.fa3.third_party import InvoiceThirdParty +class KsefInvoiceDraft(KSeFBaseModel): + """Serializable editable snapshot of FA(3) builder state.""" + + header: Annotated[ + InvoiceHeader | None, Field(description="Draft header state.") + ] = None + seller: Annotated[ + InvoiceEntity | None, Field(description="Draft seller state.") + ] = None + buyer: Annotated[InvoiceEntity | None, Field(description="Draft buyer state.")] = ( + None + ) + third_parties: Annotated[ + list[InvoiceThirdParty], Field(description="Draft third-party state.") + ] = Field(default_factory=list) + body: Annotated[ + KsefInvoiceBody | None, Field(description="Draft invoice body state.") + ] = None + footer: Annotated[ + InvoiceFooter | None, Field(description="Draft footer state.") + ] = None + attachment: Annotated[ + Attachment | None, Field(description="Draft attachment state.") + ] = None + + @classmethod + def from_invoice(cls, invoice: "KsefInvoice") -> "KsefInvoiceDraft": + """Create an editable draft snapshot from a complete invoice.""" + return cls( + header=invoice.header.model_copy(deep=True), + seller=invoice.seller.model_copy(deep=True), + buyer=invoice.buyer.model_copy(deep=True), + third_parties=[ + third_party.model_copy(deep=True) + for third_party in invoice.third_parties + ], + body=invoice.body.model_copy(deep=True), + footer=invoice.footer.model_copy(deep=True) if invoice.footer else None, + attachment=( + invoice.attachment.model_copy(deep=True) if invoice.attachment else None + ), + ) + + class KsefInvoice(KSeFBaseModel): """Root public aggregate for a FA(3) invoice.""" diff --git a/src/ksef2/domain/models/fa3/references.py b/src/ksef2/domain/models/fa3/references.py index b33068c..cbe7e59 100644 --- a/src/ksef2/domain/models/fa3/references.py +++ b/src/ksef2/domain/models/fa3/references.py @@ -2,12 +2,23 @@ from datetime import date from decimal import Decimal +from enum import StrEnum from pydantic import model_validator from ksef2.domain.models import KSeFBaseModel +class DraftIntent(StrEnum): + """Invoice intent tracked by serialized FA(3) builder drafts.""" + + STANDARD = "VAT" + CORRECTION = "KOR" + ADVANCE = "ZAL" + SETTLEMENT = "ROZ" + MARGIN = "MARZA" + + class CorrectedInvoiceReference(KSeFBaseModel): """Reference to an invoice corrected by a correction invoice.""" diff --git a/src/ksef2/domain/protocols.py b/src/ksef2/domain/protocols.py new file mode 100644 index 0000000..bc21e5f --- /dev/null +++ b/src/ksef2/domain/protocols.py @@ -0,0 +1,39 @@ +import abc +from typing import Self + +from ksef2.domain.models.fa3 import ( + InvoiceHeader, + InvoiceEntity, + InvoiceThirdParty, + InvoiceFooter, + Attachment, +) + + +class BaseBuilderProtocol(abc.ABC): + _header: InvoiceHeader | None + _seller: InvoiceEntity | None + _buyer: InvoiceEntity | None + _third_parties: list[InvoiceThirdParty] | None + _footer: InvoiceFooter | None + _attachment: Attachment | None + + @abc.abstractmethod + def header_model(self, header: InvoiceHeader) -> Self: + raise NotImplementedError + + @abc.abstractmethod + def seller_model(self, seller: InvoiceEntity) -> Self: + raise NotImplementedError + + @abc.abstractmethod + def buyer_model(self, buyer: InvoiceEntity) -> Self: + raise NotImplementedError + + @abc.abstractmethod + def footer_model(self, footer: InvoiceFooter) -> Self: + raise NotImplementedError + + @abc.abstractmethod + def attachment_model(self, attachment: Attachment) -> Self: + raise NotImplementedError diff --git a/src/ksef2/fa3/__init__.py b/src/ksef2/fa3/__init__.py new file mode 100644 index 0000000..1f5b433 --- /dev/null +++ b/src/ksef2/fa3/__init__.py @@ -0,0 +1,42 @@ +"""Public FA(3) API facade.""" + +from ksef2.domain.models.fa3 import ( + ContactInfo, + InvoiceAddress, + InvoiceEntity, + InvoiceHeader, + InvoiceThirdParty, + KsefInvoice, + KsefInvoiceDraft, +) +from ksef2.domain.models.fa3.body import ( + InvoiceSummaryOverrides, + SaleCategory, + TaxRegime, + VatClassification, + VatRate, + VatTreatment, +) +from ksef2.services.builders.fa3.root import StandardInvoiceBuilder + + +class FA3InvoiceBuilder(StandardInvoiceBuilder): + """Canonical public FA(3) invoice builder.""" + + +__all__ = [ + "ContactInfo", + "FA3InvoiceBuilder", + "InvoiceAddress", + "InvoiceEntity", + "InvoiceHeader", + "InvoiceSummaryOverrides", + "InvoiceThirdParty", + "KsefInvoice", + "KsefInvoiceDraft", + "SaleCategory", + "TaxRegime", + "VatClassification", + "VatRate", + "VatTreatment", +] diff --git a/src/ksef2/services/builders/__init__.py b/src/ksef2/services/builders/__init__.py new file mode 100644 index 0000000..28576ad --- /dev/null +++ b/src/ksef2/services/builders/__init__.py @@ -0,0 +1,15 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ksef2.fa3 import FA3InvoiceBuilder + +__all__ = ["FA3InvoiceBuilder"] + + +def __getattr__(name: str) -> object: + if name == "FA3InvoiceBuilder": + from ksef2.fa3 import FA3InvoiceBuilder + + return FA3InvoiceBuilder + + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/ksef2/services/builders/fa3/__init__.py b/src/ksef2/services/builders/fa3/__init__.py new file mode 100644 index 0000000..d2f3a64 --- /dev/null +++ b/src/ksef2/services/builders/fa3/__init__.py @@ -0,0 +1 @@ +"""FA(3) fluent builder package exports.""" diff --git a/src/ksef2/services/builders/fa3/attachment.py b/src/ksef2/services/builders/fa3/attachment.py new file mode 100644 index 0000000..e753235 --- /dev/null +++ b/src/ksef2/services/builders/fa3/attachment.py @@ -0,0 +1,439 @@ +"""Fluent builders for FA(3) attachment blocks.""" + +from typing import Annotated, Self +from collections.abc import Callable + +from ksef2.domain.models.fa3.attachment import ( + Attachment, + AttachmentTable, + DataBlock, + ValueType, +) +from ksef2.services.builders.fa3.metadata import builder_param + + +class AttachmentTableBuilder: + """Fluent builder for FA(3) attachment tables.""" + + def __init__( + self, + parent: "DataBlockBuilder | None" = None, + existing_state: AttachmentTable | None = None, + ) -> None: + self._parent = parent + self._meta_data: list[dict[str, str]] = ( + list(existing_state.meta_data) if existing_state else [] + ) + self._description: str | None = ( + existing_state.description if existing_state else None + ) + self._columns_names: list[str] | None = ( + list(existing_state.columns_names) + if existing_state and existing_state.columns_names + else None + ) + self._columns_format: list[ValueType] = ( + list(existing_state.columns_format) if existing_state else [] + ) + self._rows: list[list[str]] = ( + [list(row) for row in existing_state.rows] if existing_state else [] + ) + self._summary: list[str] | None = ( + list(existing_state.summary) + if existing_state and existing_state.summary + else None + ) + + def from_model(self, table: AttachmentTable | None) -> Self: + """Replace the builder state from an existing domain model.""" + table = table.model_copy(deep=True) if table is not None else None + self._meta_data = list(table.meta_data) if table else [] + self._description = table.description if table else None + self._columns_names = ( + list(table.columns_names) if table and table.columns_names else None + ) + self._columns_format = list(table.columns_format) if table else [] + self._rows = [list(row) for row in table.rows] if table else [] + self._summary = list(table.summary) if table and table.summary else None + return self + + def set_description( + self, + description: Annotated[ + str | None, + builder_param( + "Description of the attachment table.", + examples=["Specification of delivered items"], + priority="advanced", + ), + ], + ) -> Self: + """Set the attachment table description.""" + self._description = description + return self + + def add_meta_data( + self, + key: Annotated[ + str, + builder_param( + "Metadata key stored next to the attachment table.", + examples=["document_type"], + priority="advanced", + ), + ], + value: Annotated[ + str, + builder_param( + "Metadata value stored next to the attachment table.", + examples=["technical_specification"], + priority="advanced", + ), + ], + ) -> Self: + """Add a meta data entry.""" + self._meta_data.append({key: value}) + return self + + def clear_meta_data(self) -> Self: + """Remove all metadata entries.""" + self._meta_data = [] + return self + + def set_columns( + self, + formats: Annotated[ + list[ValueType], + builder_param( + "Column value types for the attachment table.", + examples=[], + format="object", + priority="advanced", + schema_ref="ksef2.domain.models.fa3.attachment.ValueType", + ), + ], + names: Annotated[ + list[str] | None, + builder_param( + "Column names shown in the attachment table.", + examples=[["Item", "Quantity", "Net value"]], + priority="advanced", + ), + ] = None, + ) -> Self: + """Set attachment table column formats and optional names.""" + self._columns_format = list(formats) + self._columns_names = list(names) if names is not None else None + return self + + def add_row( + self, + row: Annotated[ + list[str], + builder_param( + "Single row added to the attachment table.", + examples=[["Keyboard", "2", "199.00"]], + priority="advanced", + ), + ], + ) -> Self: + """Add a row entry.""" + self._rows.append([str(value) for value in row]) + return self + + def add_rows( + self, + rows: Annotated[ + list[list[str]], + builder_param( + "Multiple rows added to the attachment table.", + examples=[[["Keyboard", "2", "199.00"], ["Mouse", "1", "99.00"]]], + priority="advanced", + ), + ], + ) -> Self: + """Add multiple row entries to the attachment table.""" + self._rows.extend([[str(value) for value in row] for row in rows]) + return self + + def clear_rows(self) -> Self: + """Remove all rows.""" + self._rows = [] + return self + + def set_summary( + self, + summary: Annotated[ + list[str] | None, + builder_param( + "Summary lines shown below the attachment table.", + examples=[["Total net: 298.00"]], + priority="advanced", + ), + ], + ) -> Self: + """Set the summary value.""" + self._summary = list(summary) if summary is not None else None + return self + + def build(self) -> AttachmentTable: + """Build the corresponding FA(3) domain model.""" + return AttachmentTable( + meta_data=self._meta_data, + description=self._description, + columns_names=self._columns_names, + columns_format=self._columns_format, + rows=self._rows, + summary=self._summary, + ) + + def _is_empty(self) -> bool: + return ( + not self._rows + and not self._meta_data + and self._description is None + and not self._columns_format + and self._columns_names is None + and self._summary is None + ) + + def done(self) -> "DataBlockBuilder": + """Attach the built table to the parent data-block builder. + + Raises: + ValueError: If the table is empty or the builder has no parent. + """ + if self._parent is None: + raise ValueError( + "AttachmentTableBuilder must have a parent DataBlockBuilder to call done()." + ) + if self._is_empty(): + raise ValueError( + "Attachment table is empty. Add at least one row before calling done()." + ) + _ = self._parent.add_table_model(self.build()) + return self._parent + + +class DataBlockBuilder: + """Fluent builder for FA(3) attachment data blocks.""" + + def __init__( + self, + parent: "AttachmentBuilder[object] | None" = None, + existing_state: DataBlock | None = None, + ) -> None: + self._parent = parent + self._header: str | None = existing_state.header if existing_state else None + self._meta_data: list[dict[str, str]] = ( + list(existing_state.meta_data) + if existing_state and existing_state.meta_data + else [] + ) + self._paragraphs: list[str] = ( + list(existing_state.paragraphs) + if existing_state and existing_state.paragraphs + else [] + ) + self._tables: list[AttachmentTable] = ( + [table.model_copy(deep=True) for table in existing_state.tables] + if existing_state and existing_state.tables + else [] + ) + + def from_model(self, block: DataBlock | None) -> Self: + """Replace the builder state from an existing domain model.""" + block = block.model_copy(deep=True) if block is not None else None + self._header = block.header if block else None + self._meta_data = list(block.meta_data) if block and block.meta_data else [] + self._paragraphs = list(block.paragraphs) if block and block.paragraphs else [] + self._tables = list(block.tables) if block and block.tables else [] + return self + + def set_header( + self, + header: Annotated[ + str | None, + builder_param( + "Header shown above the attachment data block.", + examples=["Technical specification"], + priority="advanced", + ), + ], + ) -> Self: + """Set the attachment data-block header.""" + self._header = header + return self + + def add_meta_data( + self, + key: Annotated[ + str, + builder_param( + "Metadata key stored next to the data block.", + examples=["source"], + priority="advanced", + ), + ], + value: Annotated[ + str, + builder_param( + "Metadata value stored next to the data block.", + examples=["warehouse_system"], + priority="advanced", + ), + ], + ) -> Self: + """Add a meta data entry.""" + self._meta_data.append({key: value}) + return self + + def clear_meta_data(self) -> Self: + """Remove all metadata entries.""" + self._meta_data = [] + return self + + def add_paragraph( + self, + text: Annotated[ + str, + builder_param( + "Paragraph text added to the attachment data block.", + examples=["The goods were inspected before dispatch."], + priority="advanced", + ), + ], + ) -> Self: + """Add a paragraph entry.""" + self._paragraphs.append(text) + return self + + def clear_paragraphs(self) -> Self: + """Remove all paragraphs.""" + self._paragraphs = [] + return self + + def build_table(self) -> AttachmentTableBuilder: + """Start a table builder.""" + return AttachmentTableBuilder(self, None) + + def add_table_model(self, table: AttachmentTable) -> Self: + """Add an existing attachment table model.""" + self._tables.append(table) + return self + + def clear_tables(self) -> Self: + """Remove all attachment tables.""" + self._tables = [] + return self + + def build(self) -> DataBlock: + """Build the corresponding FA(3) domain model.""" + return DataBlock( + header=self._header, + meta_data=self._meta_data if self._meta_data else None, + paragraphs=self._paragraphs if self._paragraphs else None, + tables=self._tables if self._tables else None, + ) + + def _is_empty(self) -> bool: + return ( + self._header is None + and not self._meta_data + and not self._paragraphs + and not self._tables + ) + + def done(self) -> "AttachmentBuilder[object]": + """Attach the built data block to the parent attachment builder. + + Raises: + ValueError: If the data block is empty or the builder has no parent. + """ + if self._parent is None: + raise ValueError( + "DataBlockBuilder must have a parent AttachmentBuilder to call done()." + ) + if self._is_empty(): + raise ValueError( + "Attachment data block is empty. Set at least one field before calling done()." + ) + _ = self._parent.add_data_block_model(self.build()) + return self._parent + + +class AttachmentBuilder[TParent]: + """Fluent builder for FA(3) invoice attachments.""" + + def __init__( + self, + parent: TParent | None = None, + on_done: Callable[[Attachment], None] | None = None, + existing_state: Attachment | None = None, + ) -> None: + self._parent = parent + self._on_done = on_done + self._data_blocks: list[DataBlock] = ( + [block.model_copy(deep=True) for block in existing_state.data_blocks] + if existing_state + else [] + ) + + def from_model(self, attachment: Attachment | None) -> Self: + """Replace the builder state from an existing domain model.""" + attachment = ( + attachment.model_copy(deep=True) if attachment is not None else None + ) + self._data_blocks = list(attachment.data_blocks) if attachment else [] + return self + + def build_data_block(self) -> DataBlockBuilder: + """Start a data block builder.""" + return DataBlockBuilder(self, None) + + def add_data_block_model(self, block: DataBlock) -> Self: + """Add an existing attachment data-block model.""" + self._data_blocks.append(block) + return self + + def clear_data_blocks(self) -> Self: + """Remove all attachment data blocks.""" + self._data_blocks = [] + return self + + def build(self) -> Attachment: + """Build the corresponding FA(3) domain model.""" + return Attachment(data_blocks=self._data_blocks) + + def _is_empty(self) -> bool: + return not self._data_blocks + + def done(self) -> TParent: + """Attach the built attachment to the parent invoice builder. + + Raises: + ValueError: If the attachment is empty or the builder has no parent. + """ + if self._parent is None or self._on_done is None: + raise ValueError( + "AttachmentBuilder must have a parent builder to call done()." + ) + if self._is_empty(): + raise ValueError( + "Attachment details are empty. Add at least one data block before calling done()." + ) + self._on_done(self.build()) + return self._parent + + +class AttachmentBuilderMixin: + """Mixin exposing the Attachment sub-builder.""" + + _attachment: Attachment | None = None + + def attachment(self) -> AttachmentBuilder[Self]: + """Start an attachment sub-builder.""" + return AttachmentBuilder(self, self._set_attachment, self._attachment) + + def _set_attachment(self, attachment: Attachment) -> None: + self._attachment = attachment diff --git a/src/ksef2/services/builders/fa3/body/__init__.py b/src/ksef2/services/builders/fa3/body/__init__.py new file mode 100644 index 0000000..eda83d6 --- /dev/null +++ b/src/ksef2/services/builders/fa3/body/__init__.py @@ -0,0 +1,23 @@ +"""FA(3) invoice body builder exports.""" + +from ksef2.services.builders.fa3.body.advance import AdvanceBodyBuilder +from ksef2.services.builders.fa3.body.correction import CorrectionBodyBuilder +from ksef2.services.builders.fa3.body.correction_advance import ( + CorrectionAdvanceBodyBuilder, +) +from ksef2.services.builders.fa3.body.correction_settlement import ( + CorrectionSettlementBodyBuilder, +) +from ksef2.services.builders.fa3.body.simplified import SimplifiedBodyBuilder +from ksef2.services.builders.fa3.body.settlement import SettlementBodyBuilder +from ksef2.services.builders.fa3.body.standard import StandardBodyBuilder + +__all__ = [ + "AdvanceBodyBuilder", + "CorrectionBodyBuilder", + "CorrectionAdvanceBodyBuilder", + "CorrectionSettlementBodyBuilder", + "SettlementBodyBuilder", + "SimplifiedBodyBuilder", + "StandardBodyBuilder", +] diff --git a/src/ksef2/services/builders/fa3/body/advance.py b/src/ksef2/services/builders/fa3/body/advance.py new file mode 100644 index 0000000..c2dc980 --- /dev/null +++ b/src/ksef2/services/builders/fa3/body/advance.py @@ -0,0 +1,99 @@ +"""Fluent builder for advance invoice bodies.""" + +from typing import Self, override +from collections.abc import Callable + +from ksef2.domain.models.fa3.body import InvoiceType +from ksef2.domain.models.fa3.body.root import KsefInvoiceBody +from ksef2.services.builders.fa3.body.base import BaseBodyBuilder +from ksef2.services.builders.fa3.sub.advance import AdvanceBuilderMixin +from ksef2.services.builders.fa3.sub.annotations import AnnotationsBuilderMixin +from ksef2.services.builders.fa3.sub.order import OrderBuilderMixin +from ksef2.services.builders.fa3.sub.payment import PaymentBuilderMixin +from ksef2.services.builders.fa3.sub.transaction import TransactionBuilderMixin + + +class AdvanceBodyBuilder[TParent]( + BaseBodyBuilder, + OrderBuilderMixin, + PaymentBuilderMixin, + AnnotationsBuilderMixin, + TransactionBuilderMixin, + AdvanceBuilderMixin, +): + """Fluent builder for Advance invoice bodies.""" + + def __init__( + self, + parent: TParent | None = None, + on_done: Callable[[KsefInvoiceBody], None] | None = None, + existing_state: KsefInvoiceBody | None = None, + ) -> None: + self._parent = parent + self._on_done = on_done + BaseBodyBuilder.__init__(self, existing_state=existing_state) + self._order = ( + existing_state.order.model_copy(deep=True) + if existing_state and existing_state.order is not None + else None + ) + self._payment = ( + existing_state.payment.model_copy(deep=True) + if existing_state and existing_state.payment is not None + else None + ) + self._annotations = ( + existing_state.annotations.model_copy(deep=True) + if existing_state and existing_state.annotations is not None + else None + ) + self._transaction_conditions = ( + existing_state.transaction_conditions.model_copy(deep=True) + if existing_state and existing_state.transaction_conditions is not None + else None + ) + self._advance = ( + existing_state.advance.model_copy(deep=True) + if existing_state and existing_state.advance is not None + else None + ) + + def build(self) -> KsefInvoiceBody: + """Build the corresponding FA(3) domain model.""" + return KsefInvoiceBody( + **self._state, + invoice_type=InvoiceType.ZAL, + order=self._order, + payment=self._payment, + annotations=self._annotations, + transaction_conditions=self._transaction_conditions, + advance=self._advance, + ) + + @override + def from_model(self, body: KsefInvoiceBody) -> Self: + """Replace the builder state from an existing domain model.""" + _ = BaseBodyBuilder.from_model(self, body) + self._order = body.order.model_copy(deep=True) if body.order else None + self._payment = body.payment.model_copy(deep=True) if body.payment else None + self._annotations = ( + body.annotations.model_copy(deep=True) if body.annotations else None + ) + self._transaction_conditions = ( + body.transaction_conditions.model_copy(deep=True) + if body.transaction_conditions + else None + ) + self._advance = body.advance.model_copy(deep=True) if body.advance else None + return self + + def done(self) -> TParent: + """Attach the built invoice body to the parent invoice builder. + + Raises: + ValueError: If this body builder has no parent invoice builder. + """ + if self._parent is None or self._on_done is None: + raise ValueError("AdvanceBodyBuilder requires a parent to call done().") + self._on_done(self.build()) + return self._parent diff --git a/src/ksef2/services/builders/fa3/body/base.py b/src/ksef2/services/builders/fa3/body/base.py new file mode 100644 index 0000000..95a5846 --- /dev/null +++ b/src/ksef2/services/builders/fa3/body/base.py @@ -0,0 +1,316 @@ +"""Shared fluent builder methods for FA(3) invoice bodies.""" + +from datetime import date +from decimal import Decimal +from typing import Annotated, Self, TypedDict + +from pydantic import TypeAdapter + +from ksef2.domain.models.fa3 import KsefInvoiceBody, AdditionalDescriptionEntry +from ksef2.domain.models.fa3.body import InvoiceSummaryOverrides +from ksef2.services.builders.fa3.metadata import builder_param + + +class BodyCoreState(TypedDict): + """Typed state for common FA(3) body fields.""" + + currency: str + issue_date: date + issue_place: str | None + invoice_number: str + warehouse_documents: list[str] + date_of_supply: date | None + period_start: date | None + period_end: date | None + vat_currency_exchange_rate: Decimal | None + fp_invoice: bool + related_party_transaction: bool + additional_description: list[AdditionalDescriptionEntry] + return_of_excise: bool | None + summary_overrides: InvoiceSummaryOverrides | None + + +adapter = TypeAdapter(BodyCoreState) + + +def _default_state() -> BodyCoreState: + return { + "currency": "PLN", + "issue_date": date.today(), + "issue_place": None, + "invoice_number": "not-specified", + "warehouse_documents": [], + "date_of_supply": None, + "period_start": None, + "period_end": None, + "vat_currency_exchange_rate": None, + "fp_invoice": False, + "related_party_transaction": False, + "additional_description": [], + "return_of_excise": None, + "summary_overrides": None, + } + + +class BaseBodyBuilder: + """Shared fluent builder for common FA(3) body fields.""" + + def __init__(self, existing_state: KsefInvoiceBody | None = None) -> None: + self._state = adapter.validate_python( + existing_state.model_dump() if existing_state else _default_state() + ) + + def from_model(self, body: KsefInvoiceBody) -> Self: + """Replace the builder state from an existing domain model.""" + self._state = adapter.validate_python(body.model_dump()) + return self + + def currency( + self, + value: Annotated[ + str, + builder_param( + "Invoice currency code. The builder stores it in uppercase.", + examples=["PLN", "EUR"], + ), + ], + ) -> Self: + """Set the currency value.""" + self._state["currency"] = value.upper() + return self + + def issue_date( + self, + value: Annotated[ + date, + builder_param( + "Date when the invoice is issued.", + examples=["2026-04-09"], + format="date", + ), + ], + ) -> Self: + """Set the issue date value.""" + self._state["issue_date"] = value + return self + + def issue_place( + self, + value: Annotated[ + str | None, + builder_param( + "Place where the invoice was issued.", + examples=["Warszawa"], + ), + ], + ) -> Self: + """Set the issue place value.""" + self._state["issue_place"] = value + return self + + def invoice_number( + self, + value: Annotated[ + str, + builder_param( + "Invoice number visible on the document.", + examples=["FV/2026/04/0001"], + ), + ], + ) -> Self: + """Set the invoice number value.""" + self._state["invoice_number"] = value + return self + + def add_warehouse_document( + self, + value: Annotated[ + str, + builder_param( + "Reference to a warehouse or stock document linked to the invoice.", + examples=["WZ/2026/04/15"], + priority="advanced", + ), + ], + ) -> Self: + """Add a warehouse document entry.""" + self._state["warehouse_documents"].append(value) + return self + + def replace_warehouse_documents(self, values: list[str]) -> Self: + """Replace the warehouse documents collection.""" + self._state["warehouse_documents"] = list(values) + return self + + def clear_warehouse_documents(self) -> Self: + """Remove all warehouse documents entries.""" + self._state["warehouse_documents"].clear() + return self + + def date_of_supply( + self, + value: Annotated[ + date | None, + builder_param( + "Supply or service completion date for the invoice.", + examples=["2026-04-08"], + format="date", + ), + ], + ) -> Self: + """Set the date of supply value.""" + self._state["date_of_supply"] = value + return self + + def billing_period( + self, + *, + period_start: Annotated[ + date | None, + builder_param( + "Start of the billing period for period-based invoices.", + examples=["2026-04-01"], + format="date", + priority="advanced", + ), + ] = None, + period_end: Annotated[ + date | None, + builder_param( + "End of the billing period for period-based invoices.", + examples=["2026-04-30"], + format="date", + priority="advanced", + ), + ] = None, + ) -> Self: + """Set the billing period value.""" + self._state["period_start"] = period_start + self._state["period_end"] = period_end + return self + + def vat_currency_exchange_rate( + self, + value: Annotated[ + Decimal | None, + builder_param( + "Exchange rate used for VAT calculations when the invoice currency differs from PLN.", + examples=["4.2512"], + format="decimal-string", + priority="advanced", + ), + ], + ) -> Self: + """Set the VAT currency exchange rate value.""" + self._state["vat_currency_exchange_rate"] = value + return self + + def mark_fp( + self, + enabled: Annotated[ + bool, + builder_param( + "Marks the invoice as an FP invoice.", + examples=[True], + priority="advanced", + ), + ] = True, + ) -> Self: + """Set the FP marker flag.""" + self._state["fp_invoice"] = enabled + return self + + def related_party_transaction( + self, + enabled: Annotated[ + bool, + builder_param( + "Marks the invoice as a related-party transaction.", + examples=[True], + priority="advanced", + ), + ] = True, + ) -> Self: + """Set the related party transaction value.""" + self._state["related_party_transaction"] = enabled + return self + + def add_description( + self, + *, + key: Annotated[ + str, + builder_param( + "Label for an additional invoice description entry.", + examples=["campaign"], + priority="advanced", + ), + ], + value: Annotated[ + str, + builder_param( + "Value stored under the additional description label.", + examples=["spring-2026"], + priority="advanced", + ), + ], + row_number: Annotated[ + int | None, + builder_param( + "Invoice row number that this additional description refers to.", + examples=[1], + priority="advanced", + ), + ] = None, + ) -> Self: + """Add a description entry.""" + self._state["additional_description"].append( + AdditionalDescriptionEntry( + row_number=row_number, + key=key, + value=value, + ) + ) + return self + + def add_description_model(self, entry: AdditionalDescriptionEntry) -> Self: + """Add an existing additional-description domain model.""" + self._state["additional_description"].append(entry) + return self + + def clear_descriptions(self) -> Self: + """Remove all additional description entries.""" + self._state["additional_description"].clear() + return self + + def return_of_excise( + self, + value: Annotated[ + bool | None, + builder_param( + "Marks the invoice as related to an excise refund scenario when required.", + examples=[True], + priority="advanced", + ), + ], + ) -> Self: + """Set the return of excise value.""" + self._state["return_of_excise"] = value + return self + + def summary_overrides( + self, + value: Annotated[ + InvoiceSummaryOverrides | None, + builder_param( + "Explicit invoice summary totals to preserve when they should not be recomputed from lines.", + examples=[], + priority="override", + format="object", + schema_ref="ksef2.domain.models.fa3.body.InvoiceSummaryOverrides", + ), + ], + ) -> Self: + """Set the summary overrides value.""" + self._state["summary_overrides"] = value + return self diff --git a/src/ksef2/services/builders/fa3/body/correction.py b/src/ksef2/services/builders/fa3/body/correction.py new file mode 100644 index 0000000..d43a111 --- /dev/null +++ b/src/ksef2/services/builders/fa3/body/correction.py @@ -0,0 +1,101 @@ +"""Fluent builder for correction invoice bodies.""" + +from typing import Self, override +from collections.abc import Callable + +from ksef2.domain.models.fa3.body import InvoiceType +from ksef2.domain.models.fa3.body.root import KsefInvoiceBody +from ksef2.services.builders.fa3.body.base import BaseBodyBuilder +from ksef2.services.builders.fa3.sub.annotations import AnnotationsBuilderMixin +from ksef2.services.builders.fa3.sub.correction import CorrectionBuilderMixin +from ksef2.services.builders.fa3.sub.payment import PaymentBuilderMixin +from ksef2.services.builders.fa3.sub.rows import RowsBuilderMixin +from ksef2.services.builders.fa3.sub.transaction import TransactionBuilderMixin + + +class CorrectionBodyBuilder[TParent]( + BaseBodyBuilder, + RowsBuilderMixin, + PaymentBuilderMixin, + AnnotationsBuilderMixin, + TransactionBuilderMixin, + CorrectionBuilderMixin, +): + """Fluent builder for Correction invoice bodies.""" + + def __init__( + self, + parent: TParent | None = None, + on_done: Callable[[KsefInvoiceBody], None] | None = None, + existing_state: KsefInvoiceBody | None = None, + ) -> None: + self._parent = parent + self._on_done = on_done + BaseBodyBuilder.__init__(self, existing_state=existing_state) + self._rows = ( + [row.model_copy(deep=True) for row in existing_state.rows] + if existing_state + else [] + ) + self._payment = ( + existing_state.payment.model_copy(deep=True) + if existing_state and existing_state.payment is not None + else None + ) + self._annotations = ( + existing_state.annotations.model_copy(deep=True) + if existing_state and existing_state.annotations is not None + else None + ) + self._transaction_conditions = ( + existing_state.transaction_conditions.model_copy(deep=True) + if existing_state and existing_state.transaction_conditions is not None + else None + ) + self._correction = ( + existing_state.correction.model_copy(deep=True) + if existing_state and existing_state.correction is not None + else None + ) + + def build(self) -> KsefInvoiceBody: + """Build the corresponding FA(3) domain model.""" + return KsefInvoiceBody( + **self._state, + invoice_type=InvoiceType.CORRECTING, + rows=list(self._rows), + payment=self._payment, + annotations=self._annotations, + transaction_conditions=self._transaction_conditions, + correction=self._correction, + ) + + @override + def from_model(self, body: KsefInvoiceBody) -> Self: + """Replace the builder state from an existing domain model.""" + _ = BaseBodyBuilder.from_model(self, body) + self._rows = [row.model_copy(deep=True) for row in body.rows] + self._payment = body.payment.model_copy(deep=True) if body.payment else None + self._annotations = ( + body.annotations.model_copy(deep=True) if body.annotations else None + ) + self._transaction_conditions = ( + body.transaction_conditions.model_copy(deep=True) + if body.transaction_conditions + else None + ) + self._correction = ( + body.correction.model_copy(deep=True) if body.correction else None + ) + return self + + def done(self) -> TParent: + """Attach the built invoice body to the parent invoice builder. + + Raises: + ValueError: If this body builder has no parent invoice builder. + """ + if self._parent is None or self._on_done is None: + raise ValueError("CorrectionBodyBuilder requires a parent to call done().") + self._on_done(self.build()) + return self._parent diff --git a/src/ksef2/services/builders/fa3/body/correction_advance.py b/src/ksef2/services/builders/fa3/body/correction_advance.py new file mode 100644 index 0000000..f9b4a99 --- /dev/null +++ b/src/ksef2/services/builders/fa3/body/correction_advance.py @@ -0,0 +1,112 @@ +"""Fluent builder for correction advance invoice bodies.""" + +from typing import Self, override +from collections.abc import Callable + +from ksef2.domain.models.fa3.body import InvoiceType +from ksef2.domain.models.fa3.body.root import KsefInvoiceBody +from ksef2.services.builders.fa3.body.base import BaseBodyBuilder +from ksef2.services.builders.fa3.sub.advance import AdvanceBuilderMixin +from ksef2.services.builders.fa3.sub.annotations import AnnotationsBuilderMixin +from ksef2.services.builders.fa3.sub.correction import CorrectionBuilderMixin +from ksef2.services.builders.fa3.sub.order import OrderBuilderMixin +from ksef2.services.builders.fa3.sub.payment import PaymentBuilderMixin +from ksef2.services.builders.fa3.sub.transaction import TransactionBuilderMixin + + +class CorrectionAdvanceBodyBuilder[TParent]( + BaseBodyBuilder, + OrderBuilderMixin, + PaymentBuilderMixin, + AnnotationsBuilderMixin, + TransactionBuilderMixin, + CorrectionBuilderMixin, + AdvanceBuilderMixin, +): + """Fluent builder for CorrectionAdvance invoice bodies.""" + + def __init__( + self, + parent: TParent | None = None, + on_done: Callable[[KsefInvoiceBody], None] | None = None, + existing_state: KsefInvoiceBody | None = None, + ) -> None: + self._parent = parent + self._on_done = on_done + BaseBodyBuilder.__init__(self, existing_state=existing_state) + self._order = ( + existing_state.order.model_copy(deep=True) + if existing_state and existing_state.order is not None + else None + ) + self._payment = ( + existing_state.payment.model_copy(deep=True) + if existing_state and existing_state.payment is not None + else None + ) + self._annotations = ( + existing_state.annotations.model_copy(deep=True) + if existing_state and existing_state.annotations is not None + else None + ) + self._transaction_conditions = ( + existing_state.transaction_conditions.model_copy(deep=True) + if existing_state and existing_state.transaction_conditions is not None + else None + ) + self._correction = ( + existing_state.correction.model_copy(deep=True) + if existing_state and existing_state.correction is not None + else None + ) + self._advance = ( + existing_state.advance.model_copy(deep=True) + if existing_state and existing_state.advance is not None + else None + ) + + def build(self) -> KsefInvoiceBody: + """Build the corresponding FA(3) domain model.""" + return KsefInvoiceBody( + **self._state, + invoice_type=InvoiceType.CORRECTING_ZAL, + order=self._order, + payment=self._payment, + annotations=self._annotations, + transaction_conditions=self._transaction_conditions, + correction=self._correction, + advance=self._advance, + ) + + @override + def from_model(self, body: KsefInvoiceBody) -> Self: + """Replace the builder state from an existing domain model.""" + _ = BaseBodyBuilder.from_model(self, body) + self._order = body.order.model_copy(deep=True) if body.order else None + self._payment = body.payment.model_copy(deep=True) if body.payment else None + self._annotations = ( + body.annotations.model_copy(deep=True) if body.annotations else None + ) + self._transaction_conditions = ( + body.transaction_conditions.model_copy(deep=True) + if body.transaction_conditions + else None + ) + self._correction = ( + body.correction.model_copy(deep=True) if body.correction else None + ) + self._advance = body.advance.model_copy(deep=True) if body.advance else None + return self + + def done(self) -> TParent: + """Attach the built invoice body to the parent invoice builder. + + Raises: + ValueError: If this body builder has no parent invoice builder. + """ + if self._parent is None or self._on_done is None: + raise ValueError( + "CorrectionAdvanceBodyBuilder requires a parent to call done()." + ) + self._on_done(self.build()) + return self._parent diff --git a/src/ksef2/services/builders/fa3/body/correction_settlement.py b/src/ksef2/services/builders/fa3/body/correction_settlement.py new file mode 100644 index 0000000..62a806d --- /dev/null +++ b/src/ksef2/services/builders/fa3/body/correction_settlement.py @@ -0,0 +1,123 @@ +"""Fluent builder for correction settlement invoice bodies.""" + +from typing import Self, override +from collections.abc import Callable + +from ksef2.domain.models.fa3.body import InvoiceType +from ksef2.domain.models.fa3.body.root import KsefInvoiceBody +from ksef2.services.builders.fa3.body.base import BaseBodyBuilder +from ksef2.services.builders.fa3.sub.advance import AdvanceBuilderMixin +from ksef2.services.builders.fa3.sub.annotations import AnnotationsBuilderMixin +from ksef2.services.builders.fa3.sub.correction import CorrectionBuilderMixin +from ksef2.services.builders.fa3.sub.payment import PaymentBuilderMixin +from ksef2.services.builders.fa3.sub.rows import RowsBuilderMixin +from ksef2.services.builders.fa3.sub.settlement import SettlementBuilderMixin +from ksef2.services.builders.fa3.sub.transaction import TransactionBuilderMixin + + +class CorrectionSettlementBodyBuilder[TParent]( + BaseBodyBuilder, + RowsBuilderMixin, + PaymentBuilderMixin, + AnnotationsBuilderMixin, + TransactionBuilderMixin, + CorrectionBuilderMixin, + AdvanceBuilderMixin, + SettlementBuilderMixin, +): + """Fluent builder for CorrectionSettlement invoice bodies.""" + + def __init__( + self, + parent: TParent | None = None, + on_done: Callable[[KsefInvoiceBody], None] | None = None, + existing_state: KsefInvoiceBody | None = None, + ) -> None: + self._parent = parent + self._on_done = on_done + BaseBodyBuilder.__init__(self, existing_state=existing_state) + self._rows = ( + [row.model_copy(deep=True) for row in existing_state.rows] + if existing_state + else [] + ) + self._payment = ( + existing_state.payment.model_copy(deep=True) + if existing_state and existing_state.payment is not None + else None + ) + self._annotations = ( + existing_state.annotations.model_copy(deep=True) + if existing_state and existing_state.annotations is not None + else None + ) + self._transaction_conditions = ( + existing_state.transaction_conditions.model_copy(deep=True) + if existing_state and existing_state.transaction_conditions is not None + else None + ) + self._correction = ( + existing_state.correction.model_copy(deep=True) + if existing_state and existing_state.correction is not None + else None + ) + self._advance = ( + existing_state.advance.model_copy(deep=True) + if existing_state and existing_state.advance is not None + else None + ) + self._settlement = ( + existing_state.settlement.model_copy(deep=True) + if existing_state and existing_state.settlement is not None + else None + ) + + def build(self) -> KsefInvoiceBody: + """Build the corresponding FA(3) domain model.""" + return KsefInvoiceBody( + **self._state, + invoice_type=InvoiceType.CORRECTING_ROZ, + rows=list(self._rows), + payment=self._payment, + annotations=self._annotations, + transaction_conditions=self._transaction_conditions, + correction=self._correction, + advance=self._advance, + settlement=self._settlement, + ) + + @override + def from_model(self, body: KsefInvoiceBody) -> Self: + """Replace the builder state from an existing domain model.""" + _ = BaseBodyBuilder.from_model(self, body) + self._rows = [row.model_copy(deep=True) for row in body.rows] + self._payment = body.payment.model_copy(deep=True) if body.payment else None + self._annotations = ( + body.annotations.model_copy(deep=True) if body.annotations else None + ) + self._transaction_conditions = ( + body.transaction_conditions.model_copy(deep=True) + if body.transaction_conditions + else None + ) + self._correction = ( + body.correction.model_copy(deep=True) if body.correction else None + ) + self._advance = body.advance.model_copy(deep=True) if body.advance else None + self._settlement = ( + body.settlement.model_copy(deep=True) if body.settlement else None + ) + return self + + def done(self) -> TParent: + """Attach the built invoice body to the parent invoice builder. + + Raises: + ValueError: If this body builder has no parent invoice builder. + """ + if self._parent is None or self._on_done is None: + raise ValueError( + "CorrectionSettlementBodyBuilder requires a parent to call done()." + ) + self._on_done(self.build()) + return self._parent diff --git a/src/ksef2/services/builders/fa3/body/settlement.py b/src/ksef2/services/builders/fa3/body/settlement.py new file mode 100644 index 0000000..ecb740e --- /dev/null +++ b/src/ksef2/services/builders/fa3/body/settlement.py @@ -0,0 +1,110 @@ +"""Fluent builder for settlement invoice bodies.""" + +from typing import Self, override +from collections.abc import Callable + +from ksef2.domain.models.fa3.body import InvoiceType +from ksef2.domain.models.fa3.body.root import KsefInvoiceBody +from ksef2.services.builders.fa3.body.base import BaseBodyBuilder +from ksef2.services.builders.fa3.sub.advance import AdvanceBuilderMixin +from ksef2.services.builders.fa3.sub.annotations import AnnotationsBuilderMixin +from ksef2.services.builders.fa3.sub.payment import PaymentBuilderMixin +from ksef2.services.builders.fa3.sub.rows import RowsBuilderMixin +from ksef2.services.builders.fa3.sub.settlement import SettlementBuilderMixin +from ksef2.services.builders.fa3.sub.transaction import TransactionBuilderMixin + + +class SettlementBodyBuilder[TParent]( + BaseBodyBuilder, + RowsBuilderMixin, + PaymentBuilderMixin, + AnnotationsBuilderMixin, + TransactionBuilderMixin, + AdvanceBuilderMixin, + SettlementBuilderMixin, +): + """Fluent builder for Settlement invoice bodies.""" + + def __init__( + self, + parent: TParent | None = None, + on_done: Callable[[KsefInvoiceBody], None] | None = None, + existing_state: KsefInvoiceBody | None = None, + ) -> None: + self._parent = parent + self._on_done = on_done + BaseBodyBuilder.__init__(self, existing_state=existing_state) + self._rows = ( + [row.model_copy(deep=True) for row in existing_state.rows] + if existing_state + else [] + ) + self._payment = ( + existing_state.payment.model_copy(deep=True) + if existing_state and existing_state.payment is not None + else None + ) + self._annotations = ( + existing_state.annotations.model_copy(deep=True) + if existing_state and existing_state.annotations is not None + else None + ) + self._transaction_conditions = ( + existing_state.transaction_conditions.model_copy(deep=True) + if existing_state and existing_state.transaction_conditions is not None + else None + ) + self._advance = ( + existing_state.advance.model_copy(deep=True) + if existing_state and existing_state.advance is not None + else None + ) + self._settlement = ( + existing_state.settlement.model_copy(deep=True) + if existing_state and existing_state.settlement is not None + else None + ) + + def build(self) -> KsefInvoiceBody: + """Build the corresponding FA(3) domain model.""" + return KsefInvoiceBody( + **self._state, + invoice_type=InvoiceType.ROZ, + rows=list(self._rows), + payment=self._payment, + annotations=self._annotations, + transaction_conditions=self._transaction_conditions, + advance=self._advance, + settlement=self._settlement, + ) + + @override + def from_model(self, body: KsefInvoiceBody) -> Self: + """Replace the builder state from an existing domain model.""" + _ = BaseBodyBuilder.from_model(self, body) + self._rows = [row.model_copy(deep=True) for row in body.rows] + self._payment = body.payment.model_copy(deep=True) if body.payment else None + self._annotations = ( + body.annotations.model_copy(deep=True) if body.annotations else None + ) + self._transaction_conditions = ( + body.transaction_conditions.model_copy(deep=True) + if body.transaction_conditions + else None + ) + self._advance = body.advance.model_copy(deep=True) if body.advance else None + self._settlement = ( + body.settlement.model_copy(deep=True) if body.settlement else None + ) + return self + + def done(self) -> TParent: + """Attach the built invoice body to the parent invoice builder. + + Raises: + ValueError: If this body builder has no parent invoice builder. + """ + if self._parent is None or self._on_done is None: + raise ValueError("SettlementBodyBuilder requires a parent to call done().") + self._on_done(self.build()) + return self._parent diff --git a/src/ksef2/services/builders/fa3/body/simplified.py b/src/ksef2/services/builders/fa3/body/simplified.py new file mode 100644 index 0000000..cde9917 --- /dev/null +++ b/src/ksef2/services/builders/fa3/body/simplified.py @@ -0,0 +1,101 @@ +"""Fluent builder for simplified invoice bodies.""" + +from typing import Self, override +from collections.abc import Callable + +from ksef2.domain.models.fa3.body import InvoiceType +from ksef2.domain.models.fa3.body.root import KsefInvoiceBody +from ksef2.services.builders.fa3.body.base import BaseBodyBuilder +from ksef2.services.builders.fa3.sub.annotations import AnnotationsBuilderMixin +from ksef2.services.builders.fa3.sub.payment import PaymentBuilderMixin +from ksef2.services.builders.fa3.sub.rows import RowsBuilderMixin +from ksef2.services.builders.fa3.sub.settlement import SettlementBuilderMixin +from ksef2.services.builders.fa3.sub.transaction import TransactionBuilderMixin + + +class SimplifiedBodyBuilder[TParent]( + BaseBodyBuilder, + RowsBuilderMixin, + PaymentBuilderMixin, + AnnotationsBuilderMixin, + TransactionBuilderMixin, + SettlementBuilderMixin, +): + """Fluent builder for Simplified invoice bodies.""" + + def __init__( + self, + parent: TParent | None = None, + on_done: Callable[[KsefInvoiceBody], None] | None = None, + existing_state: KsefInvoiceBody | None = None, + ) -> None: + self._parent = parent + self._on_done = on_done + BaseBodyBuilder.__init__(self, existing_state=existing_state) + self._rows = ( + [row.model_copy(deep=True) for row in existing_state.rows] + if existing_state + else [] + ) + self._payment = ( + existing_state.payment.model_copy(deep=True) + if existing_state and existing_state.payment is not None + else None + ) + self._annotations = ( + existing_state.annotations.model_copy(deep=True) + if existing_state and existing_state.annotations is not None + else None + ) + self._transaction_conditions = ( + existing_state.transaction_conditions.model_copy(deep=True) + if existing_state and existing_state.transaction_conditions is not None + else None + ) + self._settlement = ( + existing_state.settlement.model_copy(deep=True) + if existing_state and existing_state.settlement is not None + else None + ) + + def build(self) -> KsefInvoiceBody: + """Build the corresponding FA(3) domain model.""" + return KsefInvoiceBody( + **self._state, + invoice_type=InvoiceType.UPR, + rows=list(self._rows), + payment=self._payment, + annotations=self._annotations, + transaction_conditions=self._transaction_conditions, + settlement=self._settlement, + ) + + @override + def from_model(self, body: KsefInvoiceBody) -> Self: + """Replace the builder state from an existing domain model.""" + _ = BaseBodyBuilder.from_model(self, body) + self._rows = [row.model_copy(deep=True) for row in body.rows] + self._payment = body.payment.model_copy(deep=True) if body.payment else None + self._annotations = ( + body.annotations.model_copy(deep=True) if body.annotations else None + ) + self._transaction_conditions = ( + body.transaction_conditions.model_copy(deep=True) + if body.transaction_conditions + else None + ) + self._settlement = ( + body.settlement.model_copy(deep=True) if body.settlement else None + ) + return self + + def done(self) -> TParent: + """Attach the built invoice body to the parent invoice builder. + + Raises: + ValueError: If this body builder has no parent invoice builder. + """ + if self._parent is None or self._on_done is None: + raise ValueError("SimplifiedBodyBuilder requires a parent to call done().") + self._on_done(self.build()) + return self._parent diff --git a/src/ksef2/services/builders/fa3/body/standard.py b/src/ksef2/services/builders/fa3/body/standard.py new file mode 100644 index 0000000..ef794a1 --- /dev/null +++ b/src/ksef2/services/builders/fa3/body/standard.py @@ -0,0 +1,101 @@ +"""Fluent builder for standard invoice bodies.""" + +from typing import Self, override +from collections.abc import Callable + +from ksef2.domain.models.fa3.body.root import KsefInvoiceBody +from ksef2.services.builders.fa3.body.base import BaseBodyBuilder +from ksef2.services.builders.fa3.sub.annotations import AnnotationsBuilderMixin +from ksef2.services.builders.fa3.sub.payment import PaymentBuilderMixin +from ksef2.services.builders.fa3.sub.rows import RowsBuilderMixin +from ksef2.services.builders.fa3.sub.settlement import SettlementBuilderMixin +from ksef2.services.builders.fa3.sub.transaction import TransactionBuilderMixin +from ksef2.domain.models.fa3.body import InvoiceType + + +class StandardBodyBuilder[TParent]( + BaseBodyBuilder, + RowsBuilderMixin, + PaymentBuilderMixin, + AnnotationsBuilderMixin, + TransactionBuilderMixin, + SettlementBuilderMixin, +): + """Fluent builder for Standard invoice bodies.""" + + def __init__( + self, + parent: TParent | None = None, + on_done: Callable[[KsefInvoiceBody], None] | None = None, + existing_state: KsefInvoiceBody | None = None, + ) -> None: + self._parent = parent + self._on_done = on_done + BaseBodyBuilder.__init__(self, existing_state=existing_state) + self._rows = ( + [row.model_copy(deep=True) for row in existing_state.rows] + if existing_state + else [] + ) + self._payment = ( + existing_state.payment.model_copy(deep=True) + if existing_state and existing_state.payment is not None + else None + ) + self._annotations = ( + existing_state.annotations.model_copy(deep=True) + if existing_state and existing_state.annotations is not None + else None + ) + self._transaction_conditions = ( + existing_state.transaction_conditions.model_copy(deep=True) + if existing_state and existing_state.transaction_conditions is not None + else None + ) + self._settlement = ( + existing_state.settlement.model_copy(deep=True) + if existing_state and existing_state.settlement is not None + else None + ) + + def build(self) -> KsefInvoiceBody: + """Build the corresponding FA(3) domain model.""" + return KsefInvoiceBody( + **self._state, + invoice_type=InvoiceType.VAT, + rows=list(self._rows), + payment=self._payment, + annotations=self._annotations, + transaction_conditions=self._transaction_conditions, + settlement=self._settlement, + ) + + @override + def from_model(self, body: KsefInvoiceBody) -> Self: + """Replace the builder state from an existing domain model.""" + _ = BaseBodyBuilder.from_model(self, body) + self._rows = [row.model_copy(deep=True) for row in body.rows] + self._payment = body.payment.model_copy(deep=True) if body.payment else None + self._annotations = ( + body.annotations.model_copy(deep=True) if body.annotations else None + ) + self._transaction_conditions = ( + body.transaction_conditions.model_copy(deep=True) + if body.transaction_conditions + else None + ) + self._settlement = ( + body.settlement.model_copy(deep=True) if body.settlement else None + ) + return self + + def done(self) -> TParent: + """Attach the built invoice body to the parent invoice builder. + + Raises: + ValueError: If this body builder has no parent invoice builder. + """ + if self._parent is None or self._on_done is None: + raise ValueError("StandardBodyBuilder requires a parent to call done().") + self._on_done(self.build()) + return self._parent diff --git a/src/ksef2/services/builders/fa3/footer.py b/src/ksef2/services/builders/fa3/footer.py new file mode 100644 index 0000000..efffa8b --- /dev/null +++ b/src/ksef2/services/builders/fa3/footer.py @@ -0,0 +1,157 @@ +"""Fluent builders for FA(3) invoice footer blocks.""" + +from typing import Annotated, Self, TypedDict +from collections.abc import Callable + +from pydantic import TypeAdapter + +from ksef2.domain.models.fa3 import FooterRegistry, InvoiceFooter +from ksef2.services.builders.fa3.metadata import builder_param + + +class InvoiceFooterState(TypedDict): + """Typed state for FA(3) footer fields.""" + + additional_informations: list[str] + registries: list[FooterRegistry] + + +adapter = TypeAdapter(InvoiceFooterState) + + +def _default_state() -> InvoiceFooterState: + return { + "additional_informations": [], + "registries": [], + } + + +class FooterBuilder[TParent]: + """Fluent builder for FA(3) invoice footer details.""" + + def __init__( + self, + parent: TParent, + on_done: Callable[[InvoiceFooter], None], + existing_state: InvoiceFooter | None = None, + ) -> None: + self._parent = parent + self._on_done = on_done + self._state: InvoiceFooterState = adapter.validate_python( + existing_state.model_dump() if existing_state else _default_state() + ) + + def from_model(self, footer: InvoiceFooter) -> Self: + """Replace the builder state from an existing domain model.""" + self._state = adapter.validate_python(footer.model_dump()) + return self + + def add_information( + self, + information: Annotated[ + str, + builder_param( + "Additional footer information shown below the invoice body.", + examples=["Invoice generated electronically."], + priority="advanced", + ), + ], + ) -> Self: + """Add an invoice footer information entry.""" + self._state["additional_informations"].append(information) + return self + + def clear_informations(self) -> Self: + """Remove all informations entries.""" + self._state["additional_informations"].clear() + return self + + def add_registry( + self, + *, + full_name: Annotated[ + str | None, + builder_param( + "Full registry name shown in the footer.", + examples=["District Court in Warsaw, 13th Commercial Division"], + priority="advanced", + ), + ] = None, + krs: Annotated[ + str | None, + builder_param( + "KRS number shown in the footer registry block.", + examples=["0000123456"], + priority="advanced", + ), + ] = None, + regon: Annotated[ + str | None, + builder_param( + "REGON number shown in the footer registry block.", + examples=["123456789"], + priority="advanced", + ), + ] = None, + bdo: Annotated[ + str | None, + builder_param( + "BDO number shown in the footer registry block.", + examples=["000123456"], + priority="advanced", + ), + ] = None, + ) -> Self: + """Add a registry entry to the invoice footer.""" + self._state["registries"].append( + FooterRegistry( + full_name=full_name, + krs=krs, + regon=regon, + bdo=bdo, + ) + ) + return self + + def add_registry_model(self, registry: FooterRegistry) -> Self: + """Add an existing footer registry model.""" + self._state["registries"].append(registry) + return self + + def clear_registries(self) -> Self: + """Remove all footer registry entries.""" + self._state["registries"].clear() + return self + + def build(self) -> InvoiceFooter: + """Build the corresponding FA(3) domain model.""" + return InvoiceFooter(**self._state) + + def _is_empty(self) -> bool: + return self._state == _default_state() + + def done(self) -> TParent: + """Attach the built footer to the parent builder and return the parent. + + Raises: + ValueError: If footer details are empty. + """ + if self._is_empty(): + raise ValueError( + "Footer details are empty. Set at least one field before calling done()." + ) + self._on_done(self.build()) + return self._parent + + +class FooterBuilderMixin: + """Mixin exposing the Footer sub-builder.""" + + _footer: InvoiceFooter | None = None + + def footer(self) -> FooterBuilder[Self]: + """Start a footer sub-builder.""" + return FooterBuilder(self, self._set_footer, self._footer) + + def _set_footer(self, value: InvoiceFooter) -> None: + self._footer = value diff --git a/src/ksef2/services/builders/fa3/metadata.py b/src/ksef2/services/builders/fa3/metadata.py new file mode 100644 index 0000000..858506f --- /dev/null +++ b/src/ksef2/services/builders/fa3/metadata.py @@ -0,0 +1,51 @@ +"""Metadata helpers used by FA(3) fluent builders.""" + +from collections.abc import Sequence +from typing import Literal, cast + +from pydantic import Field +from pydantic.config import JsonDict +from pydantic.fields import FieldInfo + +BuilderPriority = Literal["common", "advanced", "override"] +BuilderFormat = Literal[ + "date", + "date-time", + "decimal-string", + "enum-string", + "object", + "country-code", +] + + +def builder_param( + description: str, + *, + examples: Sequence[object] | None = None, + format: BuilderFormat | None = None, + priority: BuilderPriority | None = None, + schema_ref: str | None = None, + prefer_omit_when_null: bool = True, +) -> FieldInfo: + """Create API-reference metadata for a fluent builder parameter.""" + json_schema_extra = cast( + JsonDict, + { + "x-builder-prefer-omit-when-null": prefer_omit_when_null, + }, + ) + if format is not None: + json_schema_extra["x-builder-format"] = format + if priority is not None: + json_schema_extra["x-builder-priority"] = priority + if schema_ref is not None: + json_schema_extra["x-builder-schema-ref"] = schema_ref + + return cast( + FieldInfo, + Field( + description=description, + examples=list(examples) if examples is not None else None, + json_schema_extra=json_schema_extra, + ), + ) diff --git a/src/ksef2/services/builders/fa3/root.py b/src/ksef2/services/builders/fa3/root.py new file mode 100644 index 0000000..df9d836 --- /dev/null +++ b/src/ksef2/services/builders/fa3/root.py @@ -0,0 +1,871 @@ +"""Root fluent builder for complete FA(3) invoices.""" + +from collections.abc import Sequence +from datetime import datetime +from decimal import Decimal +from typing import Annotated, Self, override + +from xsdata.formats.dataclass.serializers import XmlSerializer +from xsdata.formats.dataclass.serializers.config import SerializerConfig + +from ksef2.domain.models.fa3 import Attachment, InvoiceFooter, KsefInvoiceDraft +from ksef2.domain.models.fa3.body.root import KsefInvoiceBody +from ksef2.domain.protocols import BaseBuilderProtocol +from ksef2.domain.models.fa3.header import InvoiceHeader +from ksef2.domain.models.fa3.invoice import KsefInvoice +from ksef2.domain.models.fa3.party import ( + ContactInfo, + ContactInfoTuple, + InvoiceAddress, + InvoiceEntity, +) +from ksef2.domain.models.fa3.third_party import InvoiceThirdParty, ThirdPartyRole +from ksef2.infra.mappers.helpers import to_aware_datetime +from ksef2.infra.mappers.invoices.fa3.domain.invoice import to_spec as invoice_to_spec +from ksef2.infra.schema.fa3.models.schemat import Faktura, __NAMESPACE__ +from ksef2.services.builders.fa3.attachment import AttachmentBuilderMixin +from ksef2.services.builders.fa3.footer import FooterBuilderMixin +from ksef2.services.builders.fa3.body.advance import AdvanceBodyBuilder +from ksef2.services.builders.fa3.body.correction import CorrectionBodyBuilder +from ksef2.services.builders.fa3.body.correction_advance import ( + CorrectionAdvanceBodyBuilder, +) +from ksef2.services.builders.fa3.body.correction_settlement import ( + CorrectionSettlementBodyBuilder, +) +from ksef2.services.builders.fa3.body.settlement import SettlementBodyBuilder +from ksef2.services.builders.fa3.body.simplified import SimplifiedBodyBuilder +from ksef2.services.builders.fa3.body.standard import StandardBodyBuilder +from ksef2.services.builders.fa3.metadata import builder_param + + +class StandardInvoiceBuilder( + BaseBuilderProtocol, + FooterBuilderMixin, + AttachmentBuilderMixin, +): + """Fluent builder for complete FA(3) invoices.""" + + def __init__(self) -> None: + self._header: InvoiceHeader | None = InvoiceHeader() + self._seller: InvoiceEntity | None = None + self._buyer: InvoiceEntity | None = None + self._third_parties: list[InvoiceThirdParty] | None = None + self._body: KsefInvoiceBody | None = None + self._footer: InvoiceFooter | None = None + self._attachment: Attachment | None = None + + def header( + self, + *, + generation_timestamp: Annotated[ + datetime | str | None, + builder_param( + "Invoice generation timestamp written to the FA(3) header. Leave it empty to use the current time.", + examples=["2026-04-09T10:15:00+02:00"], + format="date-time", + priority="advanced", + ), + ] = None, + system_info: Annotated[ + str | None, + builder_param( + "Name of the application or service that generated the invoice.", + examples=["my-erp", "billing-service"], + ), + ] = None, + ) -> Self: + """Set the header value.""" + self._header = InvoiceHeader( + generation_timestamp=to_aware_datetime( + generation_timestamp or datetime.now() + ), + system_info=system_info, + ) + return self + + @override + def header_model(self, header: InvoiceHeader) -> Self: + """Set the invoice header from an existing domain model.""" + self._header = header + return self + + def seller( + self, + *, + name: Annotated[ + str, + builder_param( + "Seller name shown on the invoice.", + examples=["ACME sp. z o.o."], + ), + ], + country_code: Annotated[ + str, + builder_param( + "Two-letter country code for the seller address.", + examples=["PL", "DE"], + format="country-code", + ), + ], + address_line_1: Annotated[ + str, + builder_param( + "First seller address line, typically street and building number.", + examples=["ul. Przykladowa 10"], + ), + ], + tax_id: Annotated[ + str | None, + builder_param( + "Seller tax identifier, usually the NIP for Polish entities.", + examples=["1234567890"], + ), + ] = None, + address_line_2: Annotated[ + str | None, + builder_param( + "Second seller address line, typically postal code and city.", + examples=["00-001 Warszawa"], + ), + ] = None, + gln: Annotated[ + str | None, + builder_param( + "Seller GLN identifier when the seller is identified in logistics systems.", + examples=["5901234123457"], + priority="advanced", + ), + ] = None, + vat_prefix: Annotated[ + str | None, + builder_param( + "Seller VAT prefix used together with domestic tax identifiers.", + examples=["PL"], + priority="advanced", + ), + ] = None, + eu_vat_id: Annotated[ + str | None, + builder_param( + "Seller EU VAT identifier used for intra-EU transactions.", + examples=["PL1234567890"], + priority="advanced", + ), + ] = None, + other_id: Annotated[ + str | None, + builder_param( + "Alternative seller identifier when tax_id or eu_vat_id is not used.", + examples=["REG-445566"], + priority="advanced", + ), + ] = None, + eori_number: Annotated[ + str | None, + builder_param( + "Seller EORI number for customs-related invoice scenarios.", + examples=["PL123456789000000"], + priority="advanced", + ), + ] = None, + customer_number: Annotated[ + str | None, + builder_param( + "Seller customer number used by the trading parties.", + examples=["CUS-0001"], + priority="advanced", + ), + ] = None, + email: Annotated[ + str | None, + builder_param( + "Seller contact email included in invoice party details.", + examples=["billing@example.com"], + priority="advanced", + ), + ] = None, + phone: Annotated[ + str | None, + builder_param( + "Seller contact phone number included in invoice party details.", + examples=["+48 123 456 789"], + priority="advanced", + ), + ] = None, + ) -> Self: + """Set the seller party from address and identifier fields.""" + self._seller = self._build_entity( + name=name, + country_code=country_code, + address_line_1=address_line_1, + tax_id=tax_id, + address_line_2=address_line_2, + gln=gln, + vat_prefix=vat_prefix, + eu_vat_id=eu_vat_id, + other_id=other_id, + eori_number=eori_number, + customer_number=customer_number, + email=email, + phone=phone, + ) + return self + + @override + def seller_model(self, seller: InvoiceEntity) -> Self: + """Set the seller from an existing domain model.""" + self._seller = seller + return self + + def buyer( + self, + *, + name: Annotated[ + str | None, + builder_param( + "Buyer name shown on the invoice. Leave empty only when the invoice scenario allows it.", + examples=["XYZ GmbH"], + ), + ], + country_code: Annotated[ + str | None, + builder_param( + "Two-letter country code for the buyer address.", + examples=["PL", "DE"], + format="country-code", + ), + ], + address_line_1: Annotated[ + str | None, + builder_param( + "First buyer address line, typically street and building number.", + examples=["ul. Odbiorcza 20"], + ), + ], + tax_id: Annotated[ + str | None, + builder_param( + "Buyer tax identifier, usually the NIP for domestic invoices.", + examples=["9876543210"], + ), + ] = None, + address_line_2: Annotated[ + str | None, + builder_param( + "Second buyer address line, typically postal code and city.", + examples=["00-950 Warszawa"], + ), + ] = None, + gln: Annotated[ + str | None, + builder_param( + "Buyer GLN identifier used in logistics or EDI processes.", + examples=["5901234123457"], + priority="advanced", + ), + ] = None, + vat_prefix: Annotated[ + str | None, + builder_param( + "Buyer VAT prefix used together with domestic identifiers.", + examples=["PL"], + priority="advanced", + ), + ] = None, + eu_vat_id: Annotated[ + str | None, + builder_param( + "Buyer EU VAT identifier for intra-EU transactions.", + examples=["DE123456789"], + priority="advanced", + ), + ] = None, + other_id: Annotated[ + str | None, + builder_param( + "Alternative buyer identifier used when tax_id or eu_vat_id is not available.", + examples=["CUST-7788"], + priority="advanced", + ), + ] = None, + eori_number: Annotated[ + str | None, + builder_param( + "Buyer EORI number for customs-related invoice scenarios.", + examples=["DE123456789000000"], + priority="advanced", + ), + ] = None, + customer_number: Annotated[ + str | None, + builder_param( + "Buyer customer number used by the trading parties.", + examples=["BUY-0004"], + priority="advanced", + ), + ] = None, + buyer_id: Annotated[ + str | None, + builder_param( + "Internal buyer identifier used in some FA(3) scenarios.", + examples=["buyer-42"], + priority="advanced", + ), + ] = None, + jst_subordinate_unit: Annotated[ + bool, + builder_param( + "Set to true when the buyer is a subordinate unit of a Polish local government entity.", + examples=[False], + priority="advanced", + ), + ] = False, + vat_group_member: Annotated[ + bool, + builder_param( + "Set to true when the buyer belongs to a VAT group.", + examples=[False], + priority="advanced", + ), + ] = False, + email: Annotated[ + str | None, + builder_param( + "Buyer contact email included in invoice party details.", + examples=["ap@example.com"], + priority="advanced", + ), + ] = None, + phone: Annotated[ + str | None, + builder_param( + "Buyer contact phone number included in invoice party details.", + examples=["+49 30 123456"], + priority="advanced", + ), + ] = None, + ) -> Self: + """Set the buyer party from address and identifier fields. + + Raises: + ValueError: If an address line is provided without a country code. + """ + self._buyer = self._build_entity( + name=name, + country_code=country_code, + address_line_1=address_line_1, + tax_id=tax_id, + address_line_2=address_line_2, + gln=gln, + vat_prefix=vat_prefix, + eu_vat_id=eu_vat_id, + other_id=other_id, + eori_number=eori_number, + customer_number=customer_number, + buyer_id=buyer_id, + jst_subordinate_unit=jst_subordinate_unit, + vat_group_member=vat_group_member, + email=email, + phone=phone, + ) + return self + + @override + def buyer_model(self, buyer: InvoiceEntity) -> Self: + """Set the buyer from an existing domain model.""" + self._buyer = buyer + return self + + def third_party( + self, + *, + name: Annotated[ + str, + builder_param( + "Third-party name shown in the FA(3) party section.", + examples=["Logistics Partner sp. z o.o."], + ), + ], + tax_id: Annotated[ + str | None, + builder_param( + "Third-party tax identifier.", + examples=["1234567890"], + ), + ] = None, + internal_id: Annotated[ + str | None, + builder_param( + "Internal identifier assigned to the third party.", + examples=["TP-001"], + priority="advanced", + ), + ] = None, + eu_vat_id: Annotated[ + str | None, + builder_param( + "EU VAT identifier for the third party.", + examples=["PL1234567890"], + priority="advanced", + ), + ] = None, + identity_country_code: Annotated[ + str | None, + builder_param( + "Country code attached to the third-party identity data.", + examples=["PL", "DE"], + format="country-code", + priority="advanced", + ), + ] = None, + other_id: Annotated[ + str | None, + builder_param( + "Alternative third-party identifier.", + examples=["ALT-7788"], + priority="advanced", + ), + ] = None, + no_id: Annotated[ + bool, + builder_param( + "Set to true when the third party is intentionally recorded without an identifier.", + examples=[False], + priority="advanced", + ), + ] = False, + address_country_code: Annotated[ + str | None, + builder_param( + "Country code for the third-party address.", + examples=["PL"], + format="country-code", + priority="advanced", + ), + ] = None, + address_line_1: Annotated[ + str | None, + builder_param( + "First line of the third-party address.", + examples=["ul. Partnera 1"], + priority="advanced", + ), + ] = None, + address_line_2: Annotated[ + str | None, + builder_param( + "Second line of the third-party address.", + examples=["00-100 Warszawa"], + priority="advanced", + ), + ] = None, + gln: Annotated[ + str | None, + builder_param( + "GLN assigned to the third-party address.", + examples=["5901234123457"], + priority="advanced", + ), + ] = None, + correspondence_country_code: Annotated[ + str | None, + builder_param( + "Country code for the correspondence address.", + examples=["PL"], + format="country-code", + priority="advanced", + ), + ] = None, + correspondence_address_line_1: Annotated[ + str | None, + builder_param( + "First line of the third-party correspondence address.", + examples=["ul. Korespondencyjna 7"], + priority="advanced", + ), + ] = None, + correspondence_address_line_2: Annotated[ + str | None, + builder_param( + "Second line of the third-party correspondence address.", + examples=["00-120 Warszawa"], + priority="advanced", + ), + ] = None, + correspondence_gln: Annotated[ + str | None, + builder_param( + "GLN assigned to the correspondence address.", + examples=["5901234123457"], + priority="advanced", + ), + ] = None, + contacts: Annotated[ + Sequence[ContactInfoTuple] | None, + builder_param( + "Contact entries for the third party, such as email and phone.", + examples=[], + priority="advanced", + schema_ref="ksef2.domain.models.fa3.party.ContactInfoTuple", + ), + ] = None, + role: Annotated[ + ThirdPartyRole | None, + builder_param( + "Role of the third party in the invoice context.", + examples=["factor"], + format="enum-string", + priority="advanced", + ), + ] = None, + other_role: Annotated[ + bool, + builder_param( + "Set to true when the role is described manually instead of using the predefined role enum.", + examples=[False], + priority="advanced", + ), + ] = False, + role_description: Annotated[ + str | None, + builder_param( + "Free-text description of the third-party role.", + examples=["Customs representative"], + priority="advanced", + ), + ] = None, + share_percentage: Annotated[ + Decimal | None, + builder_param( + "Share percentage assigned to the third party when the invoice scenario requires it.", + examples=["50.00"], + format="decimal-string", + priority="advanced", + ), + ] = None, + customer_number: Annotated[ + str | None, + builder_param( + "Customer number assigned to the third party.", + examples=["TP-CUST-1"], + priority="advanced", + ), + ] = None, + eori_number: Annotated[ + str | None, + builder_param( + "Third-party EORI number.", + examples=["PL123456789000000"], + priority="advanced", + ), + ] = None, + buyer_id: Annotated[ + str | None, + builder_param( + "Buyer identifier linked to the third party when required.", + examples=["buyer-42"], + priority="advanced", + ), + ] = None, + ) -> Self: + """Add a third-party subject from address, identity, and role fields. + + Raises: + ValueError: If address or correspondence-address fields are incomplete. + """ + address = None + if address_line_1 is not None: + if address_country_code is None: + raise ValueError( + "address_country_code is required when providing a third-party address." + ) + address = self._build_address( + country_code=address_country_code, + address_line_1=address_line_1, + address_line_2=address_line_2, + gln=gln, + ) + + correspondence_address = None + if any( + ( + correspondence_country_code, + correspondence_address_line_1, + correspondence_address_line_2, + correspondence_gln, + ) + ): + if correspondence_address_line_1 is None: + raise ValueError( + "correspondence_address_line_1 is required when providing a correspondence address." + ) + if correspondence_country_code is None: + raise ValueError( + "correspondence_country_code is required when providing a correspondence address." + ) + correspondence_address = self._build_address( + country_code=correspondence_country_code, + address_line_1=correspondence_address_line_1, + address_line_2=correspondence_address_line_2, + gln=correspondence_gln, + ) + + built_contacts = None + if contacts is not None: + built_contacts = [ + ContactInfo(email=contact.email, phone=contact.phone) + for contact in contacts + ] + + third_party = InvoiceThirdParty( + tax_id=tax_id, + internal_id=internal_id, + eu_vat_id=eu_vat_id, + country_code=identity_country_code, + other_id=other_id, + no_id=no_id, + name=name, + address=address, + correspondence_address=correspondence_address, + contact=built_contacts, + role=role, + other_role=other_role, + role_description=role_description, + share_percentage=share_percentage, + customer_number=customer_number, + eori_number=eori_number, + buyer_id=buyer_id, + ) + return self.add_third_party_model(third_party) + + def add_third_party_model(self, third_party: InvoiceThirdParty) -> Self: + """Add an existing third-party domain model.""" + if self._third_parties is None: + self._third_parties = [] + self._third_parties.append(third_party) + return self + + def replace_third_parties(self, third_parties: Sequence[InvoiceThirdParty]) -> Self: + """Replace all third-party subjects.""" + self._third_parties = list(third_parties) + return self + + def clear_third_parties(self) -> Self: + """Remove all third-party subjects.""" + self._third_parties = [] + return self + + @override + def footer_model(self, footer: InvoiceFooter) -> Self: + """Set the footer from an existing domain model.""" + self._footer = footer + return self + + @override + def attachment_model(self, attachment: Attachment) -> Self: + """Set the attachment from an existing domain model.""" + self._attachment = attachment + return self + + def standard(self) -> StandardBodyBuilder[Self]: + """Start a standard invoice body builder.""" + return StandardBodyBuilder(self, self._set_body, self._body) + + def simplified(self) -> SimplifiedBodyBuilder[Self]: + """Start a simplified invoice body builder.""" + return SimplifiedBodyBuilder(self, self._set_body, self._body) + + def correction(self) -> CorrectionBodyBuilder[Self]: + """Start a correction invoice body builder.""" + return CorrectionBodyBuilder(self, self._set_body, self._body) + + def advance(self) -> AdvanceBodyBuilder[Self]: + """Start an advance invoice body builder or sub-builder.""" + return AdvanceBodyBuilder(self, self._set_body, self._body) + + def settlement(self) -> SettlementBodyBuilder[Self]: + """Start a settlement invoice body builder or sub-builder.""" + return SettlementBodyBuilder(self, self._set_body, self._body) + + def correction_advance(self) -> CorrectionAdvanceBodyBuilder[Self]: + """Start a correction advance invoice body builder.""" + return CorrectionAdvanceBodyBuilder(self, self._set_body, self._body) + + def correction_settlement(self) -> CorrectionSettlementBodyBuilder[Self]: + """Start a correction settlement invoice body builder.""" + return CorrectionSettlementBodyBuilder(self, self._set_body, self._body) + + def _set_body(self, body: KsefInvoiceBody) -> None: + self._body = body + + def dump_state(self) -> KsefInvoiceDraft: + """Return a serializable draft snapshot of the current builder state.""" + return KsefInvoiceDraft( + header=self._header.model_copy(deep=True) if self._header else None, + seller=self._seller.model_copy(deep=True) if self._seller else None, + buyer=self._buyer.model_copy(deep=True) if self._buyer else None, + third_parties=[ + third_party.model_copy(deep=True) + for third_party in (self._third_parties or []) + ], + body=self._body.model_copy(deep=True) if self._body else None, + footer=self._footer.model_copy(deep=True) if self._footer else None, + attachment=( + self._attachment.model_copy(deep=True) if self._attachment else None + ), + ) + + def dump_state_json(self, *, indent: int | None = None) -> str: + """Return the current builder state as JSON.""" + return self.dump_state().model_dump_json(indent=indent) + + def load_state(self, state: KsefInvoiceDraft) -> Self: + """Load builder state from a serializable draft snapshot.""" + self._header = state.header.model_copy(deep=True) if state.header else None + self._seller = state.seller.model_copy(deep=True) if state.seller else None + self._buyer = state.buyer.model_copy(deep=True) if state.buyer else None + self._third_parties = [ + third_party.model_copy(deep=True) for third_party in state.third_parties + ] + self._body = state.body.model_copy(deep=True) if state.body else None + self._footer = state.footer.model_copy(deep=True) if state.footer else None + self._attachment = ( + state.attachment.model_copy(deep=True) if state.attachment else None + ) + return self + + @classmethod + def from_state(cls, state: KsefInvoiceDraft) -> Self: + """Create a builder from a serializable draft snapshot.""" + return cls().load_state(state) + + @classmethod + def from_state_json(cls, data: str | bytes | bytearray) -> Self: + """Create a builder from serialized JSON state.""" + return cls.from_state(KsefInvoiceDraft.model_validate_json(data)) + + @classmethod + def from_invoice(cls, invoice: KsefInvoice) -> Self: + """Create a builder initialized from an existing invoice.""" + return cls.from_state(KsefInvoiceDraft.from_invoice(invoice)) + + def build(self) -> KsefInvoice: + """Build the corresponding FA(3) domain model. + + Raises: + ValueError: If the required header, seller, buyer, or body is missing. + """ + if self._header is None: + raise ValueError("Invoice header is required but not set.") + if self._seller is None: + raise ValueError("Invoice seller is required but not set.") + if self._buyer is None: + raise ValueError("Invoice buyer is required but not set.") + if self._body is None: + raise ValueError("Invoice body is required but not set.") + + if self._third_parties is None: + self._third_parties = [] + + return KsefInvoice( + header=self._header, + seller=self._seller, + buyer=self._buyer, + third_parties=self._third_parties, + body=self._body, + footer=self._footer, + attachment=self._attachment, + ) + + def to_spec(self) -> Faktura: + """Convert the built invoice to the generated FA(3) schema model. + + Raises: + ValueError: If the current builder state cannot build a complete invoice. + """ + return invoice_to_spec(self.build()) + + def to_xml( + self, + *, + pretty_print: bool = True, + xml_declaration: bool = True, + encoding: str = "UTF-8", + ) -> str: + """Serialize the built invoice to XML bytes. + + Raises: + ValueError: If the current builder state cannot build a complete invoice. + """ + serializer = XmlSerializer( + config=SerializerConfig( + pretty_print=pretty_print, + xml_declaration=xml_declaration, + encoding=encoding, + ) + ) + return serializer.render( # pyright: ignore[reportUnknownMemberType] + self.to_spec(), ns_map={None: __NAMESPACE__} + ) + + @staticmethod + def _build_entity( + *, + name: str | None, + country_code: str | None, + address_line_1: str | None, + tax_id: str | None, + address_line_2: str | None, + gln: str | None, + vat_prefix: str | None, + eu_vat_id: str | None, + other_id: str | None = None, + eori_number: str | None = None, + customer_number: str | None = None, + buyer_id: str | None = None, + jst_subordinate_unit: bool = False, + vat_group_member: bool = False, + email: str | None = None, + phone: str | None = None, + ) -> InvoiceEntity: + contact = ContactInfo(email=email, phone=phone) if email or phone else None + address = None + if address_line_1: + if country_code is None: + raise ValueError("country_code is required when providing an address.") + address = StandardInvoiceBuilder._build_address( + country_code=country_code, + address_line_1=address_line_1, + address_line_2=address_line_2, + gln=gln, + ) + return InvoiceEntity( + tax_id=tax_id, + eu_vat_id=eu_vat_id, + other_id=other_id, + eori_number=eori_number, + customer_number=customer_number, + buyer_id=buyer_id, + jst_subordinate_unit=jst_subordinate_unit, + vat_group_member=vat_group_member, + vat_prefix=vat_prefix, + name=name, + address=address, + contact=contact, + ) + + @staticmethod + def _build_address( + *, + country_code: str, + address_line_1: str, + address_line_2: str | None = None, + gln: str | None = None, + ) -> InvoiceAddress: + return InvoiceAddress( + country_code=country_code, + address_line_1=address_line_1, + address_line_2=address_line_2, + gln=gln, + ) diff --git a/src/ksef2/services/builders/fa3/sub/__init__.py b/src/ksef2/services/builders/fa3/sub/__init__.py new file mode 100644 index 0000000..e6beb56 --- /dev/null +++ b/src/ksef2/services/builders/fa3/sub/__init__.py @@ -0,0 +1 @@ +"""FA(3) invoice body sub-builder exports.""" diff --git a/src/ksef2/services/builders/fa3/sub/advance.py b/src/ksef2/services/builders/fa3/sub/advance.py new file mode 100644 index 0000000..e5a4d66 --- /dev/null +++ b/src/ksef2/services/builders/fa3/sub/advance.py @@ -0,0 +1,239 @@ +"""Fluent builder for advance-payment invoice context.""" + +from datetime import date +from decimal import Decimal +from typing import Annotated, Self, TypedDict +from collections.abc import Callable + +from pydantic import TypeAdapter + +from ksef2.domain.models.fa3 import ( + AdvanceInvoiceReference, + AdvancePaymentInvoiceContext, + PartialAdvancePayment, +) +from ksef2.services.builders.fa3.metadata import builder_param + + +class AdvanceState(TypedDict): + """Typed state for Advance fields.""" + + amount_before_correction: Decimal | None + currency_exchange_rate_before_correction: Decimal | None + advance_partial_payments: list[PartialAdvancePayment] + advance_invoice_references: list[AdvanceInvoiceReference] + + +adapter = TypeAdapter(AdvanceState) + + +def _default_state() -> AdvanceState: + return { + "amount_before_correction": None, + "currency_exchange_rate_before_correction": None, + "advance_partial_payments": [], + "advance_invoice_references": [], + } + + +class AdvanceBuilder[TParent]: + """Fluent builder for FA(3) advance-payment details.""" + + def __init__( + self, + parent: TParent, + on_done: Callable[[AdvancePaymentInvoiceContext], None], + existing_state: AdvancePaymentInvoiceContext | None = None, + ) -> None: + self._parent = parent + self._on_done = on_done + self._state: AdvanceState = adapter.validate_python( + existing_state.model_dump() if existing_state else _default_state() + ) + + def from_model(self, advance: AdvancePaymentInvoiceContext) -> Self: + """Replace the builder state from an existing domain model.""" + self._state = adapter.validate_python(advance.model_dump()) + return self + + def amount_before_correction( + self, + amount: Annotated[ + Decimal | None, + builder_param( + "Advance amount before the correction was applied.", + examples=["1500.45"], + format="decimal-string", + priority="advanced", + ), + ], + ) -> Self: + """Set the amount before correction value.""" + self._state["amount_before_correction"] = amount + return self + + def currency_exchange_rate_before_correction( + self, + exchange_rate: Annotated[ + Decimal | None, + builder_param( + "Currency exchange rate used before the correction was applied.", + examples=["4.4512"], + format="decimal-string", + priority="advanced", + ), + ], + ) -> Self: + """Set the currency exchange rate before correction value.""" + self._state["currency_exchange_rate_before_correction"] = exchange_rate + return self + + def add_partial_payment( + self, + *, + payment_date: Annotated[ + date, + builder_param( + "Date of the partial advance payment.", + examples=["2026-04-05"], + format="date", + ), + ], + amount: Annotated[ + Decimal, + builder_param( + "Amount of the partial advance payment.", + examples=["500.00"], + format="decimal-string", + ), + ], + currency_exchange_rate: Annotated[ + Decimal | None, + builder_param( + "Currency exchange rate used for the partial advance payment.", + examples=["4.4512"], + format="decimal-string", + priority="advanced", + ), + ] = None, + ) -> Self: + """Add a partial payment entry.""" + self._state["advance_partial_payments"].append( + PartialAdvancePayment( + payment_date=payment_date, + amount=amount, + currency_exchange_rate=currency_exchange_rate, + ) + ) + return self + + def add_partial_payment_model(self, partial_payment: PartialAdvancePayment) -> Self: + """Add an existing partial-payment domain model.""" + self._state["advance_partial_payments"].append(partial_payment) + return self + + def clear_partial_payments(self) -> Self: + """Remove all partial-payment entries.""" + self._state["advance_partial_payments"].clear() + return self + + def add_invoice_reference( + self, + *, + ksef_id: Annotated[ + str | None, + builder_param( + "KSeF identifier of the referenced advance invoice.", + examples=["20260405-1234567890-ABCDEF1234567890"], + priority="advanced", + ), + ] = None, + invoice_number: Annotated[ + str | None, + builder_param( + "Invoice number of the referenced advance invoice.", + examples=["ZAL/2026/04/001"], + ), + ] = None, + outside_ksef: Annotated[ + bool, + builder_param( + "Set to true when the referenced advance invoice was issued outside KSeF.", + examples=[False], + priority="advanced", + ), + ] = False, + deduction_amount: Annotated[ + Decimal | None, + builder_param( + "Amount deducted from the final settlement based on this advance invoice.", + examples=["500.00"], + format="decimal-string", + priority="advanced", + ), + ] = None, + deduction_reason: Annotated[ + str | None, + builder_param( + "Reason for the deduction linked to the advance invoice reference.", + examples=["Advance already settled"], + priority="advanced", + ), + ] = None, + ) -> Self: + """Add an invoice reference entry.""" + self._state["advance_invoice_references"].append( + AdvanceInvoiceReference( + ksef_id=ksef_id, + invoice_number=invoice_number, + outside_ksef=outside_ksef, + deduction_amount=deduction_amount, + deduction_reason=deduction_reason, + ) + ) + return self + + def add_invoice_reference_model( + self, invoice_reference: AdvanceInvoiceReference + ) -> Self: + """Add an existing invoice-reference domain model.""" + self._state["advance_invoice_references"].append(invoice_reference) + return self + + def clear_invoice_references(self) -> Self: + """Remove all invoice-reference entries.""" + self._state["advance_invoice_references"].clear() + return self + + def build(self) -> AdvancePaymentInvoiceContext: + """Build the corresponding FA(3) domain model.""" + return AdvancePaymentInvoiceContext(**self._state) + + def _is_empty(self) -> bool: + return self._state == _default_state() + + def done(self) -> TParent: + """Attach the built advance details to the parent builder and return it. + + Raises: + ValueError: If advance details are empty. + """ + if self._is_empty(): + raise ValueError( + "Advance details are empty. Set at least one field before calling done()." + ) + self._on_done(self.build()) + return self._parent + + +class AdvanceBuilderMixin: + """Mixin exposing the Advance sub-builder.""" + + _advance: AdvancePaymentInvoiceContext | None = None + + def advance(self) -> AdvanceBuilder[Self]: + """Start an advance invoice body builder or sub-builder.""" + return AdvanceBuilder(self, self._set_advance, self._advance) + + def _set_advance(self, value: AdvancePaymentInvoiceContext) -> None: + self._advance = value diff --git a/src/ksef2/services/builders/fa3/sub/annotations.py b/src/ksef2/services/builders/fa3/sub/annotations.py new file mode 100644 index 0000000..7d8528b --- /dev/null +++ b/src/ksef2/services/builders/fa3/sub/annotations.py @@ -0,0 +1,479 @@ +"""Fluent builder for FA(3) invoice annotations.""" + +from datetime import date +from typing import Annotated, Self, TypedDict +from collections.abc import Callable + +from pydantic import TypeAdapter + +from ksef2.domain.models.fa3 import ( + InvoiceAnnotationsContext, + InvoiceTaxExemption, + MarginProcedure, + NewTransportMeansItem, + NewTransportSupply, +) +from ksef2.services.builders.fa3.metadata import builder_param + + +class InvoiceAnnotationsState(TypedDict): + """Typed state for FA(3) annotation fields.""" + + cash_accounting: bool + self_billing: bool + reverse_charge_annotation: bool + split_payment: bool + simplified_procedure: bool + margin_procedure: MarginProcedure | None + tax_exemption: InvoiceTaxExemption | None + + +adapter = TypeAdapter(InvoiceAnnotationsState) + + +def _default_state() -> InvoiceAnnotationsState: + return { + "cash_accounting": False, + "self_billing": False, + "reverse_charge_annotation": False, + "split_payment": False, + "simplified_procedure": False, + "margin_procedure": None, + "tax_exemption": None, + } + + +class AnnotationsBuilder[TParent]: + """Fluent builder for FA(3) invoice annotations.""" + + def __init__( + self, + parent: TParent, + on_done: Callable[[InvoiceAnnotationsContext], None], + existing_state: InvoiceAnnotationsContext | None = None, + ) -> None: + self._parent = parent + self._on_done = on_done + if existing_state is None: + self._state = adapter.validate_python(_default_state()) + else: + self._state = adapter.validate_python( + { + "cash_accounting": existing_state.cash_accounting, + "self_billing": existing_state.self_billing, + "reverse_charge_annotation": existing_state.reverse_charge_annotation, + "split_payment": existing_state.split_payment, + "simplified_procedure": existing_state.simplified_procedure, + "margin_procedure": existing_state.margin_procedure, + "tax_exemption": existing_state.tax_exemption, + } + ) + new_transport = existing_state.new_transport_supply if existing_state else None + self._article_42_5_required: bool | None = ( + new_transport.article_42_5_required if new_transport else None + ) + self._new_transport_items: list[NewTransportMeansItem] = ( + list(new_transport.items) if new_transport else [] + ) + + def from_model(self, annotations: InvoiceAnnotationsContext) -> Self: + """Replace the builder state from an existing domain model.""" + self._state = adapter.validate_python( + { + "cash_accounting": annotations.cash_accounting, + "self_billing": annotations.self_billing, + "reverse_charge_annotation": annotations.reverse_charge_annotation, + "split_payment": annotations.split_payment, + "simplified_procedure": annotations.simplified_procedure, + "margin_procedure": annotations.margin_procedure, + "tax_exemption": annotations.tax_exemption, + } + ) + new_transport = annotations.new_transport_supply + self._article_42_5_required = ( + new_transport.article_42_5_required if new_transport else None + ) + self._new_transport_items = list(new_transport.items) if new_transport else [] + return self + + def cash_accounting( + self, + enabled: Annotated[ + bool, + builder_param( + "Marks the invoice with the cash accounting annotation.", + examples=[True], + priority="advanced", + ), + ] = True, + ) -> Self: + """Toggle the cash accounting annotation.""" + self._state["cash_accounting"] = enabled + return self + + def self_billing( + self, + enabled: Annotated[ + bool, + builder_param( + "Marks the invoice as self-billing.", + examples=[True], + priority="advanced", + ), + ] = True, + ) -> Self: + """Toggle the self-billing annotation.""" + self._state["self_billing"] = enabled + return self + + def reverse_charge_annotation( + self, + enabled: Annotated[ + bool, + builder_param( + "Marks the invoice with the reverse charge annotation.", + examples=[True], + priority="advanced", + ), + ] = True, + ) -> Self: + """Toggle the reverse-charge annotation.""" + self._state["reverse_charge_annotation"] = enabled + return self + + def split_payment( + self, + enabled: Annotated[ + bool, + builder_param( + "Marks the invoice with the split payment annotation.", + examples=[True], + priority="advanced", + ), + ] = True, + ) -> Self: + """Toggle the split-payment annotation.""" + self._state["split_payment"] = enabled + return self + + def simplified_procedure( + self, + enabled: Annotated[ + bool, + builder_param( + "Marks the invoice with the simplified procedure annotation.", + examples=[True], + priority="advanced", + ), + ] = True, + ) -> Self: + """Toggle the simplified-procedure annotation.""" + self._state["simplified_procedure"] = enabled + return self + + def margin_procedure( + self, + procedure: Annotated[ + MarginProcedure | str | None, + builder_param( + "Margin procedure applied to the invoice when the invoice uses margin taxation.", + examples=["travel_agency", "used_goods"], + format="enum-string", + priority="advanced", + ), + ], + ) -> Self: + """Set the margin procedure annotation.""" + if procedure is None or isinstance(procedure, MarginProcedure): + self._state["margin_procedure"] = procedure + else: + self._state["margin_procedure"] = MarginProcedure(procedure) + return self + + def tax_exemption( + self, + *, + legal_basis_act: Annotated[ + str | None, + builder_param( + "Legal basis from a domestic act used to justify the tax exemption.", + examples=["art. 43 ust. 1 pkt 10 ustawy o VAT"], + priority="advanced", + ), + ] = None, + legal_basis_eu_directive: Annotated[ + str | None, + builder_param( + "Legal basis from an EU directive used to justify the tax exemption.", + examples=["art. 132 Dyrektywy 2006/112/WE"], + priority="advanced", + ), + ] = None, + legal_basis_other: Annotated[ + str | None, + builder_param( + "Other legal basis used to justify the tax exemption.", + examples=["International agreement"], + priority="advanced", + ), + ] = None, + ) -> Self: + """Set tax-exemption legal basis details.""" + if ( + legal_basis_act is None + and legal_basis_eu_directive is None + and legal_basis_other is None + ): + self._state["tax_exemption"] = None + return self + self._state["tax_exemption"] = InvoiceTaxExemption( + legal_basis_act=legal_basis_act, + legal_basis_eu_directive=legal_basis_eu_directive, + legal_basis_other=legal_basis_other, + ) + return self + + def clear_tax_exemption(self) -> Self: + """Remove the tax-exemption annotation.""" + self._state["tax_exemption"] = None + return self + + def new_transport_supply( + self, + *, + article_42_5_required: Annotated[ + bool | None, + builder_param( + "Set when the new means of transport supply requires the Article 42(5) marker.", + examples=[True], + priority="advanced", + ), + ] = None, + ) -> Self: + """Set the new-transport supply marker.""" + self._article_42_5_required = article_42_5_required + return self + + def add_new_transport_item( + self, + *, + available_from: Annotated[ + date, + builder_param( + "Date from which the transport item was made available.", + examples=["2026-04-01"], + format="date", + priority="advanced", + ), + ], + row_number: Annotated[ + int, + builder_param( + "Row number used to identify the transport item inside the annotation block.", + examples=[1], + priority="advanced", + ), + ] = 1, + brand: Annotated[ + str | None, + builder_param( + "Brand of the new means of transport.", + examples=["Tesla"], + priority="advanced", + ), + ] = None, + model: Annotated[ + str | None, + builder_param( + "Model of the new means of transport.", + examples=["Model Y"], + priority="advanced", + ), + ] = None, + color: Annotated[ + str | None, + builder_param( + "Color of the new means of transport.", + examples=["black"], + priority="advanced", + ), + ] = None, + registration_number: Annotated[ + str | None, + builder_param( + "Registration number of the new means of transport.", + examples=["WX12345"], + priority="advanced", + ), + ] = None, + production_year: Annotated[ + str | None, + builder_param( + "Production year of the new means of transport.", + examples=["2026"], + priority="advanced", + ), + ] = None, + land_vehicle_mileage: Annotated[ + str | None, + builder_param( + "Mileage for a land vehicle.", + examples=["1200"], + priority="advanced", + ), + ] = None, + vin: Annotated[ + str | None, + builder_param( + "VIN of the transport item.", + examples=["5YJYGDEE0MF123456"], + priority="advanced", + ), + ] = None, + body_number: Annotated[ + str | None, + builder_param( + "Body number of the transport item.", + examples=["BODY-123"], + priority="advanced", + ), + ] = None, + chassis_number: Annotated[ + str | None, + builder_param( + "Chassis number of the transport item.", + examples=["CHASSIS-123"], + priority="advanced", + ), + ] = None, + frame_number: Annotated[ + str | None, + builder_param( + "Frame number of the transport item.", + examples=["FRAME-123"], + priority="advanced", + ), + ] = None, + land_vehicle_type: Annotated[ + str | None, + builder_param( + "Type of land vehicle.", + examples=["passenger car"], + priority="advanced", + ), + ] = None, + vessel_working_hours: Annotated[ + str | None, + builder_param( + "Working hours for a vessel.", + examples=["25"], + priority="advanced", + ), + ] = None, + hull_number: Annotated[ + str | None, + builder_param( + "Hull number for a vessel.", + examples=["HULL-123"], + priority="advanced", + ), + ] = None, + aircraft_working_hours: Annotated[ + str | None, + builder_param( + "Working hours for an aircraft.", + examples=["40"], + priority="advanced", + ), + ] = None, + aircraft_serial_number: Annotated[ + str | None, + builder_param( + "Serial number for an aircraft.", + examples=["AIR-123"], + priority="advanced", + ), + ] = None, + ) -> Self: + """Add a new transport item entry.""" + self._new_transport_items.append( + NewTransportMeansItem( + available_from=available_from, + row_number=row_number, + brand=brand, + model=model, + color=color, + registration_number=registration_number, + production_year=production_year, + land_vehicle_mileage=land_vehicle_mileage, + vin=vin, + body_number=body_number, + chassis_number=chassis_number, + frame_number=frame_number, + land_vehicle_type=land_vehicle_type, + vessel_working_hours=vessel_working_hours, + hull_number=hull_number, + aircraft_working_hours=aircraft_working_hours, + aircraft_serial_number=aircraft_serial_number, + ) + ) + return self + + def add_new_transport_item_model(self, item: NewTransportMeansItem) -> Self: + """Add a new transport item model entry.""" + self._new_transport_items.append(item) + return self + + def clear_new_transport_items(self) -> Self: + """Remove all new-transport-means items.""" + self._new_transport_items.clear() + self._article_42_5_required = None + return self + + def build(self) -> InvoiceAnnotationsContext: + """Build the corresponding FA(3) domain model.""" + new_transport_supply = None + if self._new_transport_items or self._article_42_5_required is not None: + new_transport_supply = NewTransportSupply( + article_42_5_required=self._article_42_5_required, + items=self._new_transport_items, + ) + return InvoiceAnnotationsContext( + cash_accounting=self._state["cash_accounting"], + self_billing=self._state["self_billing"], + reverse_charge_annotation=self._state["reverse_charge_annotation"], + split_payment=self._state["split_payment"], + tax_exemption=self._state["tax_exemption"], + new_transport_supply=new_transport_supply, + simplified_procedure=self._state["simplified_procedure"], + margin_procedure=self._state["margin_procedure"], + ) + + def _is_empty(self) -> bool: + return self._state == _default_state() + + def done(self) -> TParent: + """Attach the built annotation details to the parent builder and return it. + + Raises: + ValueError: If annotation details are empty. + """ + if self._is_empty(): + raise ValueError( + "Annotation details are empty. Set at least one field before calling done()." + ) + self._on_done(self.build()) + return self._parent + + +class AnnotationsBuilderMixin: + """Mixin exposing the Annotations sub-builder.""" + + _annotations: InvoiceAnnotationsContext | None = None + + def annotations(self) -> AnnotationsBuilder[Self]: + """Start an annotations sub-builder.""" + return AnnotationsBuilder(self, self._set_annotations, self._annotations) + + def _set_annotations(self, value: InvoiceAnnotationsContext) -> None: + self._annotations = value diff --git a/src/ksef2/services/builders/fa3/sub/correction.py b/src/ksef2/services/builders/fa3/sub/correction.py new file mode 100644 index 0000000..dc8a506 --- /dev/null +++ b/src/ksef2/services/builders/fa3/sub/correction.py @@ -0,0 +1,433 @@ +"""Fluent builder for correction invoice context.""" + +from datetime import date +from typing import Annotated, Self, TypedDict +from collections.abc import Callable + +from pydantic import TypeAdapter + +from ksef2.domain.models.fa3 import CorrectedInvoiceReference +from ksef2.domain.models.fa3.body.correction import ( + CorrectedBuyerEntity, + CorrectedSellerEntity, + CorrectionEffectType, + CorrectionInvoiceContext, +) +from ksef2.domain.models.fa3.party import InvoiceAddress +from ksef2.services.builders.fa3.metadata import builder_param + + +class InvoiceCorrectionState(TypedDict): + """Typed state for FA(3) correction fields.""" + + correction_reason: str | None + correction_effect_type: CorrectionEffectType | None + corrected_invoices: list[CorrectedInvoiceReference] + corrected_invoice_period: str | None + corrected_invoice_number_override: str | None + corrected_seller: CorrectedSellerEntity | None + corrected_buyers: list[CorrectedBuyerEntity] + + +adapter = TypeAdapter(InvoiceCorrectionState) + + +def _default_state() -> InvoiceCorrectionState: + return { + "correction_reason": None, + "correction_effect_type": None, + "corrected_invoices": [], + "corrected_invoice_period": None, + "corrected_invoice_number_override": None, + "corrected_seller": None, + "corrected_buyers": [], + } + + +def _build_address( + country_code: str, + address_line_1: str, + address_line_2: str | None = None, + gln: str | None = None, +) -> InvoiceAddress: + return InvoiceAddress( + country_code=country_code, + address_line_1=address_line_1, + address_line_2=address_line_2, + gln=gln, + ) + + +class CorrectionBuilder[TParent]: + """Fluent builder for FA(3) correction details.""" + + def __init__( + self, + parent: TParent, + on_done: Callable[[CorrectionInvoiceContext], None], + existing_state: CorrectionInvoiceContext | None = None, + ) -> None: + self._parent = parent + self._on_done = on_done + self._state: InvoiceCorrectionState = adapter.validate_python( + existing_state.model_dump() if existing_state else _default_state() + ) + + def from_model(self, correction: CorrectionInvoiceContext) -> Self: + """Replace the builder state from an existing domain model.""" + self._state = adapter.validate_python(correction.model_dump()) + return self + + def reason( + self, + value: Annotated[ + str | None, + builder_param( + "Reason for issuing the correction invoice.", + examples=["Price reduction after complaint"], + ), + ], + ) -> Self: + """Set the reason value.""" + self._state["correction_reason"] = value + return self + + def effect_type( + self, + value: Annotated[ + CorrectionEffectType | None, + builder_param( + "Correction effect type required by the FA(3) correction section.", + examples=["increase", "decrease"], + format="enum-string", + priority="advanced", + ), + ], + ) -> Self: + """Set the effect type value.""" + self._state["correction_effect_type"] = value + return self + + def add_corrected_invoice( + self, + *, + issue_date: Annotated[ + date, + builder_param( + "Issue date of the corrected invoice.", + examples=["2026-03-15"], + format="date", + ), + ], + invoice_number: Annotated[ + str, + builder_param( + "Number of the corrected invoice.", + examples=["FV/2026/03/0015"], + ), + ], + ksef_id: Annotated[ + str | None, + builder_param( + "KSeF identifier of the corrected invoice.", + examples=["20260315-1234567890-ABCDEF1234567890"], + priority="advanced", + ), + ] = None, + outside_ksef: Annotated[ + bool, + builder_param( + "Set to true when the corrected invoice was issued outside KSeF.", + examples=[False], + priority="advanced", + ), + ] = False, + ) -> Self: + """Add a corrected invoice entry.""" + self._state["corrected_invoices"].append( + CorrectedInvoiceReference( + issue_date=issue_date, + invoice_number=invoice_number, + ksef_id=ksef_id, + outside_ksef=outside_ksef, + ) + ) + return self + + def add_corrected_invoice_model( + self, corrected_invoice: CorrectedInvoiceReference + ) -> Self: + """Add an existing corrected-invoice reference model.""" + self._state["corrected_invoices"].append(corrected_invoice) + return self + + def clear_corrected_invoices(self) -> Self: + """Remove all corrected-invoice references.""" + self._state["corrected_invoices"].clear() + return self + + def corrected_invoice_period( + self, + value: Annotated[ + str | None, + builder_param( + "Accounting period covered by the corrected invoice, when the correction refers to a period instead of a single document.", + examples=["2026-03"], + priority="advanced", + ), + ], + ) -> Self: + """Set the corrected invoice period value.""" + self._state["corrected_invoice_period"] = value + return self + + def corrected_invoice_number_override( + self, + value: Annotated[ + str | None, + builder_param( + "Manual corrected invoice number used when it must differ from the referenced invoice number.", + examples=["KOR/2026/04/0001"], + priority="advanced", + ), + ], + ) -> Self: + """Set the corrected invoice number override value.""" + self._state["corrected_invoice_number_override"] = value + return self + + def corrected_seller( + self, + *, + name: Annotated[ + str, + builder_param( + "Seller name from the corrected invoice.", + examples=["ACME sp. z o.o."], + ), + ], + tax_id: Annotated[ + str, + builder_param( + "Seller tax identifier from the corrected invoice.", + examples=["1234567890"], + ), + ], + country_code: Annotated[ + str, + builder_param( + "Country code from the corrected seller address.", + examples=["PL"], + format="country-code", + ), + ], + address_line_1: Annotated[ + str, + builder_param( + "First address line from the corrected seller details.", + examples=["ul. Przykladowa 10"], + ), + ], + address_line_2: Annotated[ + str | None, + builder_param( + "Second address line from the corrected seller details.", + examples=["00-001 Warszawa"], + ), + ] = None, + gln: Annotated[ + str | None, + builder_param( + "GLN from the corrected seller address.", + examples=["5901234123457"], + priority="advanced", + ), + ] = None, + vat_prefix: Annotated[ + str | None, + builder_param( + "VAT prefix from the corrected seller identity.", + examples=["PL"], + priority="advanced", + ), + ] = None, + ) -> Self: + """Set the corrected seller value.""" + self._state["corrected_seller"] = CorrectedSellerEntity( + vat_prefix=vat_prefix, + tax_id=tax_id, + name=name, + address=_build_address( + country_code=country_code, + address_line_1=address_line_1, + address_line_2=address_line_2, + gln=gln, + ), + ) + return self + + def corrected_seller_model( + self, corrected_seller: CorrectedSellerEntity | None + ) -> Self: + """Set the corrected seller from an existing domain model.""" + self._state["corrected_seller"] = corrected_seller + return self + + def add_corrected_buyer( + self, + *, + name: Annotated[ + str, + builder_param( + "Buyer name from the corrected invoice.", + examples=["XYZ GmbH"], + ), + ], + tax_id: Annotated[ + str | None, + builder_param( + "Buyer tax identifier from the corrected invoice.", + examples=["9876543210"], + ), + ] = None, + eu_vat_id: Annotated[ + str | None, + builder_param( + "Buyer EU VAT identifier from the corrected invoice.", + examples=["DE123456789"], + priority="advanced", + ), + ] = None, + country_code: Annotated[ + str | None, + builder_param( + "Buyer identity country code from the corrected invoice.", + examples=["DE"], + format="country-code", + priority="advanced", + ), + ] = None, + address_country_code: Annotated[ + str | None, + builder_param( + "Country code for the corrected buyer address.", + examples=["DE"], + format="country-code", + priority="advanced", + ), + ] = None, + other_id: Annotated[ + str | None, + builder_param( + "Alternative buyer identifier from the corrected invoice.", + examples=["CUST-4455"], + priority="advanced", + ), + ] = None, + no_id: Annotated[ + bool, + builder_param( + "Set to true when the corrected buyer should be recorded without an identifier.", + examples=[False], + priority="advanced", + ), + ] = False, + address_line_1: Annotated[ + str | None, + builder_param( + "First address line from the corrected buyer details.", + examples=["Unter den Linden 1"], + priority="advanced", + ), + ] = None, + address_line_2: Annotated[ + str | None, + builder_param( + "Second address line from the corrected buyer details.", + examples=["10117 Berlin"], + priority="advanced", + ), + ] = None, + gln: Annotated[ + str | None, + builder_param( + "GLN from the corrected buyer address.", + examples=["4012345678901"], + priority="advanced", + ), + ] = None, + buyer_id: Annotated[ + str | None, + builder_param( + "Buyer identifier stored on the corrected invoice.", + examples=["buyer-42"], + priority="advanced", + ), + ] = None, + ) -> Self: + """Add a corrected buyer entry.""" + address = None + address_code = address_country_code or country_code + if address_code is not None and address_line_1 is not None: + address = _build_address( + country_code=address_code, + address_line_1=address_line_1, + address_line_2=address_line_2, + gln=gln, + ) + self._state["corrected_buyers"].append( + CorrectedBuyerEntity( + tax_id=tax_id, + eu_vat_id=eu_vat_id, + country_code=country_code, + other_id=other_id, + no_id=no_id, + name=name, + address=address, + buyer_id=buyer_id, + ) + ) + return self + + def add_corrected_buyer_model(self, corrected_buyer: CorrectedBuyerEntity) -> Self: + """Add an existing corrected-buyer domain model.""" + self._state["corrected_buyers"].append(corrected_buyer) + return self + + def clear_corrected_buyers(self) -> Self: + """Remove all corrected buyer entries.""" + self._state["corrected_buyers"].clear() + return self + + def build(self) -> CorrectionInvoiceContext: + """Build the corresponding FA(3) domain model.""" + return CorrectionInvoiceContext(**self._state) + + def _is_empty(self) -> bool: + return self._state == _default_state() + + def done(self) -> TParent: + """Attach the built correction details to the parent builder and return it. + + Raises: + ValueError: If correction details are empty. + """ + if self._is_empty(): + raise ValueError( + "Correction details are empty. Set at least one field before calling done()." + ) + self._on_done(self.build()) + return self._parent + + +class CorrectionBuilderMixin: + """Mixin exposing the Correction sub-builder.""" + + _correction: CorrectionInvoiceContext | None = None + + def correction(self) -> CorrectionBuilder[Self]: + """Start a correction invoice body builder.""" + return CorrectionBuilder(self, self._set_correction, self._correction) + + def _set_correction(self, value: CorrectionInvoiceContext) -> None: + self._correction = value diff --git a/src/ksef2/services/builders/fa3/sub/order.py b/src/ksef2/services/builders/fa3/sub/order.py new file mode 100644 index 0000000..5f08ee6 --- /dev/null +++ b/src/ksef2/services/builders/fa3/sub/order.py @@ -0,0 +1,353 @@ +"""Fluent builder for FA(3) order blocks.""" + +from decimal import Decimal +from collections.abc import Sequence +from typing import Annotated, Self, TypedDict +from collections.abc import Callable + +from pydantic import TypeAdapter + +from ksef2.domain.models.fa3.body import ( + SaleCategory, + TaxRegime, + VatClassification, + VatRate, +) +from ksef2.domain.models.fa3.body.order import InvoiceOrder, InvoiceOrderLine +from ksef2.domain.models.fa3.body.tax import coerce_vat_classification +from ksef2.services.builders.fa3.metadata import builder_param + + +class OrderState(TypedDict): + """Typed state for Order fields.""" + + total_value: Decimal | None + order_lines: list[InvoiceOrderLine] + + +adapter = TypeAdapter(OrderState) + +OrderAmountParam = Annotated[ + Decimal | None, + builder_param( + "Monetary value used in the order section.", + examples=["1000.00"], + format="decimal-string", + ), +] + + +def _default_state() -> OrderState: + return {"total_value": None, "order_lines": []} + + +def _coerce_vat_rate(value: VatRate | str | None) -> VatRate | None: + if value is None: + return None + if isinstance(value, VatRate): + return value + return VatRate(value) + + +def _coerce_sale_category(value: SaleCategory | str | None) -> SaleCategory | None: + if value is None: + return None + if isinstance(value, SaleCategory): + return value + return SaleCategory(value) + + +def _coerce_tax_regime(value: TaxRegime | str) -> TaxRegime: + if isinstance(value, TaxRegime): + return value + return TaxRegime(value) + + +def _coerce_vat_classification( + value: VatClassification | dict[str, object] | None, +) -> VatClassification | None: + return coerce_vat_classification(value) + + +class OrderBuilder[TParent]: + """Fluent builder for FA(3) order details.""" + + def __init__( + self, + parent: TParent, + on_done: Callable[[InvoiceOrder], None], + existing_state: InvoiceOrder | None = None, + declared_total: Decimal | None = None, + ) -> None: + self._parent = parent + self._on_done = on_done + if existing_state is None: + self._state = adapter.validate_python(_default_state()) + else: + self._state = adapter.validate_python(existing_state.model_dump()) + if declared_total is not None: + self._state["total_value"] = declared_total + + def from_model(self, order: InvoiceOrder) -> Self: + """Replace the builder state from an existing domain model.""" + self._state = adapter.validate_python(order.model_dump()) + return self + + def total_value(self, amount: OrderAmountParam) -> Self: + """Set the declared total value for the order.""" + self._state["total_value"] = amount + return self + + def add_line( + self, + *, + gross_amount: Annotated[ + Decimal, + builder_param( + "Gross amount for the order line.", + examples=["123.00"], + format="decimal-string", + ), + ], + vat_rate: Annotated[ + VatRate | str | None, + builder_param( + "VAT rate used for the order line.", + examples=["23", "8", "0", "zw"], + format="enum-string", + ), + ], + vat_classification: Annotated[ + VatClassification | dict[str, object] | None, + builder_param( + "Detailed VAT classification for non-standard order line cases.", + examples=[{"treatment": "zero_export", "rate": "0"}], + format="object", + priority="advanced", + schema_ref="ksef2.domain.models.fa3.body.tax.VatClassification", + ), + ] = None, + name: Annotated[ + str | None, + builder_param( + "Order line description.", + examples=["Prepayment for consulting service"], + ), + ] = None, + quantity: Annotated[ + Decimal | None, + builder_param( + "Quantity recorded on the order line.", + examples=["1", "2.5"], + format="decimal-string", + ), + ] = None, + unit_of_measure: Annotated[ + str | None, + builder_param( + "Unit of measure recorded on the order line.", + examples=["szt", "h"], + ), + ] = None, + unit_price_net: Annotated[ + Decimal | None, + builder_param( + "Net unit price recorded on the order line.", + examples=["100.00"], + format="decimal-string", + ), + ] = None, + sale_category: Annotated[ + SaleCategory | str | None, + builder_param( + "Sale category used for the order line when a more detailed sales context is needed.", + examples=["rate_23", "zero_wdt"], + format="enum-string", + priority="advanced", + ), + ] = None, + tax_regime: Annotated[ + TaxRegime | str, + builder_param( + "Tax regime used for the order line.", + examples=["standard", "margin"], + format="enum-string", + priority="advanced", + ), + ] = TaxRegime.STANDARD, + vat_rate_xii: Annotated[ + Decimal | None, + builder_param( + "VAT rate for Title XII order lines.", + examples=["8.50"], + format="decimal-string", + priority="advanced", + ), + ] = None, + annex_15_marker: Annotated[ + bool | None, + builder_param( + "Set when the order line is covered by Annex 15 reporting.", + examples=[True], + priority="advanced", + ), + ] = None, + unique_id: Annotated[ + str | None, + builder_param( + "Unique identifier of the order line.", + examples=["ORDER-LINE-1"], + priority="advanced", + ), + ] = None, + sku: Annotated[ + str | None, + builder_param( + "Stock keeping unit stored on the order line.", + examples=["SKU-001"], + priority="advanced", + ), + ] = None, + gtin: Annotated[ + str | None, + builder_param( + "GTIN stored on the order line.", + examples=["05901234123457"], + priority="advanced", + ), + ] = None, + pkwiu: Annotated[ + str | None, + builder_param( + "PKWiU classification stored on the order line.", + examples=["62.02.30.0"], + priority="advanced", + ), + ] = None, + cn: Annotated[ + str | None, + builder_param( + "CN code stored on the order line.", + examples=["84713000"], + priority="advanced", + ), + ] = None, + pkob: Annotated[ + str | None, + builder_param( + "PKOB code stored on the order line.", + examples=["1122"], + priority="advanced", + ), + ] = None, + gtu_code: Annotated[ + str | None, + builder_param( + "GTU code stored on the order line.", + examples=["GTU_06"], + priority="advanced", + ), + ] = None, + procedure: Annotated[ + str | None, + builder_param( + "Special procedure marker stored on the order line.", + examples=["I_42"], + priority="advanced", + ), + ] = None, + excise_amount: Annotated[ + Decimal | None, + builder_param( + "Excise amount stored on the order line when required.", + examples=["12.30"], + format="decimal-string", + priority="advanced", + ), + ] = None, + before_correction: Annotated[ + bool, + builder_param( + "Marks the order line as a before-correction value.", + examples=[False], + priority="advanced", + ), + ] = False, + ) -> Self: + """Add a line entry.""" + self._state["order_lines"].append( + InvoiceOrderLine( + gross_amount=gross_amount, + vat_rate=_coerce_vat_rate(vat_rate), + vat_classification=_coerce_vat_classification(vat_classification), + name=name, + quantity=quantity, + unit_of_measure=unit_of_measure, + unit_price_net=unit_price_net, + sale_category=_coerce_sale_category(sale_category), + tax_regime=_coerce_tax_regime(tax_regime), + vat_rate_xii=vat_rate_xii, + annex_15_marker=annex_15_marker, + unique_id=unique_id, + sku=sku, + gtin=gtin, + pkwiu=pkwiu, + cn=cn, + pkob=pkob, + gtu_code=gtu_code, + procedure=procedure, + excise_amount=excise_amount, + before_correction=before_correction, + ) + ) + return self + + def add_line_model(self, line: InvoiceOrderLine) -> Self: + """Add an existing invoice line domain model.""" + self._state["order_lines"].append(line) + return self + + def replace_lines(self, lines: Sequence[InvoiceOrderLine]) -> Self: + """Replace all invoice lines.""" + self._state["order_lines"] = list(lines) + return self + + def clear_lines(self) -> Self: + """Remove all invoice lines.""" + self._state["order_lines"].clear() + return self + + def build(self) -> InvoiceOrder: + """Build the corresponding FA(3) domain model.""" + return InvoiceOrder(**self._state) + + def _is_empty(self) -> bool: + return self._state == _default_state() + + def done(self) -> TParent: + """Attach the built order details to the parent builder and return it. + + Raises: + ValueError: If order details are empty. + """ + if self._is_empty(): + raise ValueError( + "Order details are empty. Set at least one field before calling done()." + ) + self._on_done(self.build()) + return self._parent + + +class OrderBuilderMixin: + """Mixin exposing the Order sub-builder.""" + + _order: InvoiceOrder | None = None + + def order(self, *, declared_total: Decimal | None = None) -> OrderBuilder[Self]: + """Start an order sub-builder.""" + return OrderBuilder( + self, self._set_order, self._order, declared_total=declared_total + ) + + def _set_order(self, value: InvoiceOrder) -> None: + self._order = value diff --git a/src/ksef2/services/builders/fa3/sub/payment.py b/src/ksef2/services/builders/fa3/sub/payment.py new file mode 100644 index 0000000..48e8b25 --- /dev/null +++ b/src/ksef2/services/builders/fa3/sub/payment.py @@ -0,0 +1,563 @@ +"""Fluent builder for FA(3) payment blocks.""" + +from datetime import date +from decimal import Decimal +from typing import Annotated, Self, TypedDict +from collections.abc import Callable + +from pydantic import TypeAdapter + +from ksef2.domain.models.fa3.body import ( + BankAccount, + BankOwnAccountType, + InvoicePayment, + PartialPayment, + PartialPaymentStatus, + PaymentForm, + PaymentTerm, + PaymentTermDescription, +) +from ksef2.services.builders.fa3.metadata import builder_param + + +class InvoicePaymentState(TypedDict): + """Typed state for FA(3) payment fields.""" + + paid: bool + payment_date: date | None + partial_payment_status: PartialPaymentStatus | None + partial_payments: list[PartialPayment] + payment_terms: list[PaymentTerm] + payment_form: PaymentForm | None + other_payment_form: bool + payment_description: str | None + bank_accounts: list[BankAccount] + factor_bank_accounts: list[BankAccount] + discount_terms: str | None + discount_amount: str | None + payment_link: str | None + ipksef: str | None + + +adapter = TypeAdapter(InvoicePaymentState) + +PaymentDateParam = Annotated[ + date | None, + builder_param( + "Payment date linked to the invoice or a partial payment entry.", + examples=["2026-04-15"], + format="date", + ), +] +PaymentFormParam = Annotated[ + PaymentForm | None, + builder_param( + "Payment form used for the invoice or the partial payment entry.", + examples=["bank_transfer", "cash"], + format="enum-string", + ), +] +PartialPaymentStatusParam = Annotated[ + PartialPaymentStatus | None, + builder_param( + "Partial payment status recorded on the invoice.", + examples=["partial", "final"], + format="enum-string", + priority="advanced", + ), +] +PaymentDescriptionParam = Annotated[ + str | None, + builder_param( + "Free-text payment description shown with the payment details.", + examples=["Card payment at delivery"], + priority="advanced", + ), +] +PaymentAmountParam = Annotated[ + Decimal, + builder_param( + "Monetary amount used for payment entries.", + examples=["500.00"], + format="decimal-string", + ), +] +BankAccountNumberParam = Annotated[ + str, + builder_param( + "Bank account number used for invoice payment.", + examples=["98102055580000123456789012"], + ), +] + + +def _default_state() -> InvoicePaymentState: + return { + "paid": False, + "payment_date": None, + "partial_payment_status": None, + "partial_payments": [], + "payment_terms": [], + "payment_form": None, + "other_payment_form": False, + "payment_description": None, + "bank_accounts": [], + "factor_bank_accounts": [], + "discount_terms": None, + "discount_amount": None, + "payment_link": None, + "ipksef": None, + } + + +class PaymentBuilder[TParent]: + """Fluent builder for FA(3) payment details.""" + + def __init__( + self, + parent: TParent, + on_done: Callable[[InvoicePayment], None], + existing_state: InvoicePayment | None = None, + ) -> None: + self._parent = parent + self._on_done = on_done + self._state: InvoicePaymentState = adapter.validate_python( + existing_state.model_dump() if existing_state else _default_state() + ) + + def from_model(self, payment: InvoicePayment) -> Self: + """Replace the builder state from an existing domain model.""" + self._state = adapter.validate_python(payment.model_dump()) + return self + + def via(self, payment_form: PaymentFormParam) -> Self: + """Set the invoice payment form.""" + self._state["payment_form"] = payment_form + return self + + def already_paid(self, payment_date: PaymentDateParam = None) -> Self: + """Mark the invoice as paid, optionally with a payment date.""" + self._state["paid"] = True + self._state["payment_date"] = payment_date + return self + + def unpaid(self) -> Self: + """Mark the invoice as unpaid and clear the payment date.""" + self._state["paid"] = False + self._state["payment_date"] = None + return self + + def payment_date(self, payment_date: PaymentDateParam) -> Self: + """Set the payment date value.""" + self._state["payment_date"] = payment_date + return self + + def partial_payment_status(self, status: PartialPaymentStatusParam) -> Self: + """Set the partial payment status value.""" + self._state["partial_payment_status"] = status + return self + + def other_form( + self, + enabled: Annotated[ + bool, + builder_param( + "Marks the payment form as a custom form outside the standard enum.", + examples=[True], + priority="advanced", + ), + ] = True, + ) -> Self: + """Mark the payment form as a custom non-enum value.""" + self._state["other_payment_form"] = enabled + return self + + def description(self, description: PaymentDescriptionParam) -> Self: + """Set the description value.""" + self._state["payment_description"] = description + return self + + def _add_term( + self, + *, + due_on: date | None = None, + description: PaymentTermDescription | None = None, + ) -> Self: + self._state["payment_terms"].append( + PaymentTerm(due_date=due_on, due_date_description=description) + ) + return self + + def due_on( + self, + due_date: Annotated[ + date, + builder_param( + "Invoice payment due date.", + examples=["2026-04-30"], + format="date", + ), + ], + ) -> Self: + """Add a payment term with a concrete due date.""" + return self._add_term(due_on=due_date) + + def due_with_description( + self, + *, + quantity: Annotated[ + int, + builder_param( + "Quantity used in the textual payment deadline description.", + examples=[14], + priority="advanced", + ), + ], + unit: Annotated[ + str, + builder_param( + "Unit used in the textual payment deadline description.", + examples=["days", "months"], + priority="advanced", + ), + ], + starting_event: Annotated[ + str, + builder_param( + "Event from which the payment deadline is counted.", + examples=["from invoice issue date", "from delivery"], + priority="advanced", + ), + ], + due_date: PaymentDateParam = None, + ) -> Self: + """Add a payment term described by duration and starting event.""" + return self._add_term( + due_on=due_date, + description=PaymentTermDescription( + quantity=quantity, + unit=unit, + starting_event=starting_event, + ), + ) + + def add_term_model(self, term: PaymentTerm) -> Self: + """Add an existing payment-term domain model.""" + self._state["payment_terms"].append(term) + return self + + def clear_terms(self) -> Self: + """Remove all payment terms.""" + self._state["payment_terms"].clear() + return self + + def add_partial_payment( + self, + *, + amount: PaymentAmountParam, + payment_date: Annotated[ + date, + builder_param( + "Date of the partial payment.", + examples=["2026-04-10"], + format="date", + ), + ], + payment_form: PaymentFormParam = None, + other_payment_form: Annotated[ + bool, + builder_param( + "Set to true when the partial payment uses a non-standard payment form.", + examples=[False], + priority="advanced", + ), + ] = False, + payment_description: PaymentDescriptionParam = None, + ) -> Self: + """Add a partial payment entry.""" + self._state["partial_payments"].append( + PartialPayment( + amount=amount, + payment_date=payment_date, + payment_form=payment_form, + other_payment_form=other_payment_form, + payment_description=payment_description, + ) + ) + return self + + def add_partial_payment_model(self, partial_payment: PartialPayment) -> Self: + """Add an existing partial-payment domain model.""" + self._state["partial_payments"].append(partial_payment) + return self + + def clear_partial_payments(self) -> Self: + """Remove all partial-payment entries.""" + self._state["partial_payments"].clear() + return self + + def _append_bank_account( + self, + target: list[BankAccount], + account_number: BankAccountNumberParam, + swift: Annotated[ + str | None, + builder_param( + "SWIFT or BIC code for the bank account.", + examples=["BREXPLPW"], + priority="advanced", + ), + ] = None, + *, + bank_name: Annotated[ + str | None, + builder_param( + "Name of the bank for the account.", + examples=["PKO Bank Polski"], + priority="advanced", + ), + ] = None, + account_description: Annotated[ + str | None, + builder_param( + "Description shown next to the bank account.", + examples=["Main settlement account"], + priority="advanced", + ), + ] = None, + own_bank_account_type: Annotated[ + BankOwnAccountType | None, + builder_param( + "Own-account marker used by FA(3) for the bank account entry.", + examples=["purchased_receivables"], + format="enum-string", + priority="advanced", + ), + ] = None, + ) -> None: + target.append( + BankAccount( + account_number=account_number, + swift=swift, + own_bank_account_type=own_bank_account_type, + bank_name=bank_name, + account_description=account_description, + ) + ) + + def bank_account( + self, + account_number: BankAccountNumberParam, + swift: Annotated[ + str | None, + builder_param( + "SWIFT or BIC code for the factor bank account.", + examples=["BREXPLPW"], + priority="advanced", + ), + ] = None, + *, + bank_name: Annotated[ + str | None, + builder_param( + "Name of the bank operating the factor account.", + examples=["Bank Pekao"], + priority="advanced", + ), + ] = None, + account_description: Annotated[ + str | None, + builder_param( + "Description shown next to the factor bank account.", + examples=["Factoring account"], + priority="advanced", + ), + ] = None, + own_bank_account_type: Annotated[ + BankOwnAccountType | None, + builder_param( + "Own-account marker used by FA(3) for the factor bank account entry.", + examples=["factor_collection"], + format="enum-string", + priority="advanced", + ), + ] = None, + ) -> Self: + """Add a bank account for invoice payment.""" + self._append_bank_account( + self._state["bank_accounts"], + account_number, + swift, + bank_name=bank_name, + account_description=account_description, + own_bank_account_type=own_bank_account_type, + ) + return self + + def add_bank_account_model(self, account: BankAccount) -> Self: + """Add an existing bank-account domain model.""" + self._state["bank_accounts"].append(account) + return self + + def clear_bank_accounts(self) -> Self: + """Remove all bank accounts.""" + self._state["bank_accounts"].clear() + return self + + def factor_bank_account( + self, + account_number: str, + swift: str | None = None, + *, + bank_name: str | None = None, + account_description: str | None = None, + own_bank_account_type: BankOwnAccountType | None = None, + ) -> Self: + """Add a factoring bank account for invoice payment.""" + self._append_bank_account( + self._state["factor_bank_accounts"], + account_number, + swift, + bank_name=bank_name, + account_description=account_description, + own_bank_account_type=own_bank_account_type, + ) + return self + + def add_factor_bank_account_model(self, account: BankAccount) -> Self: + """Add an existing factoring bank-account domain model.""" + self._state["factor_bank_accounts"].append(account) + return self + + def clear_factor_bank_accounts(self) -> Self: + """Remove all factoring bank accounts.""" + self._state["factor_bank_accounts"].clear() + return self + + def discount( + self, + *, + terms: Annotated[ + str | None, + builder_param( + "Description of discount terms attached to the payment.", + examples=["2% within 7 days"], + priority="advanced", + ), + ] = None, + amount: Annotated[ + str | None, + builder_param( + "Discount amount or value description stored with the payment terms.", + examples=["20.00"], + priority="advanced", + ), + ] = None, + ) -> Self: + """Set discount terms and amount for payment details.""" + self._state["discount_terms"] = terms + self._state["discount_amount"] = amount + return self + + def skonto( + self, + *, + terms: Annotated[ + str | None, + builder_param( + "Description of skonto terms attached to the payment.", + examples=["2% within 7 days"], + priority="advanced", + ), + ] = None, + amount: Annotated[ + str | None, + builder_param( + "Skonto amount or value description stored with the payment terms.", + examples=["20.00"], + priority="advanced", + ), + ] = None, + ) -> Self: + """Set skonto terms and amount for payment details.""" + return self.discount(terms=terms, amount=amount) + + def payment_link( + self, + link: Annotated[ + str | None, + builder_param( + "Link leading to an online payment page for the invoice.", + examples=["https://payments.example.com/invoice/123"], + priority="advanced", + ), + ], + ) -> Self: + """Set the payment link value.""" + self._state["payment_link"] = link + return self + + def ipksef( + self, + value: Annotated[ + str | None, + builder_param( + "IPKSeF payment identifier linked to the invoice.", + examples=["IPKSEF-123456789"], + priority="advanced", + ), + ], + ) -> Self: + """Set the IP KSeF payment marker value.""" + self._state["ipksef"] = value + return self + + def build(self) -> InvoicePayment: + """Build the corresponding FA(3) domain model. + + Raises: + ValueError: If a custom payment form is enabled without a description. + """ + self._validate_state() + return InvoicePayment(**self._state) + + def _is_empty(self) -> bool: + return self._state == _default_state() + + def done(self) -> TParent: + """Attach the built payment details to the parent builder and return it. + + Raises: + ValueError: If payment details are empty, or a custom payment form has + no description. + """ + if self._is_empty(): + raise ValueError( + "Payment details are empty. Set at least one field before calling done()." + ) + + self._on_done(self.build()) + return self._parent + + def _validate_state(self) -> None: + + if not self._state["paid"]: + self._state["payment_date"] = None + + if self._state["other_payment_form"] and not self._state["payment_description"]: + raise ValueError( + "payment_description is required when other_payment_form is enabled" + ) + + +class PaymentBuilderMixin: + """Mixin exposing the Payment sub-builder.""" + + _payment: InvoicePayment | None = None + + def payment(self) -> PaymentBuilder[Self]: + """Start a payment sub-builder.""" + return PaymentBuilder(self, self._set_payment, self._payment) + + def _set_payment(self, value: InvoicePayment) -> None: + self._payment = value diff --git a/src/ksef2/services/builders/fa3/sub/rows.py b/src/ksef2/services/builders/fa3/sub/rows.py new file mode 100644 index 0000000..b5069af --- /dev/null +++ b/src/ksef2/services/builders/fa3/sub/rows.py @@ -0,0 +1,455 @@ +"""Fluent builder for FA(3) invoice rows.""" + +from datetime import date +from decimal import Decimal +from typing import Annotated, Self, TypedDict +from collections.abc import Callable + +from pydantic import TypeAdapter + +from ksef2.domain.models.fa3 import InvoiceRow +from ksef2.domain.models.fa3.body import ( + GtuCode, + InvoiceProcedure, + SaleCategory, + TaxRegime, + VatClassification, + VatRate, +) +from ksef2.domain.models.fa3.body.tax import coerce_vat_classification +from ksef2.services.builders.fa3.metadata import builder_param + + +class RowsState(TypedDict): + """Typed state for Rows fields.""" + + rows: list[InvoiceRow] + + +adapter = TypeAdapter(RowsState) + + +_ZERO_DISCOUNT = Decimal("0.00") + +RowNameParam = Annotated[ + str, + builder_param( + "Description of the goods or service shown on the invoice line.", + examples=["Monthly accounting service", "Laptop 14 inch"], + ), +] +RowQuantityParam = Annotated[ + Decimal, + builder_param( + "Quantity billed on this line.", + examples=["1", "2.5"], + format="decimal-string", + ), +] +RowUnitPriceNetParam = Annotated[ + Decimal | None, + builder_param( + "Net unit price for one item or one service unit. Provide either this or unit_price_gross.", + examples=["100.00", "2499.99"], + format="decimal-string", + ), +] +RowUnitPriceGrossParam = Annotated[ + Decimal | None, + builder_param( + "Gross unit price for one item or one service unit. Provide either this or unit_price_net.", + examples=["123.00", "3074.99"], + format="decimal-string", + ), +] +RowVatRateParam = Annotated[ + VatRate | str | None, + builder_param( + "VAT rate used for the line when a standard VAT indicator is enough.", + examples=["23", "8", "0", "zw"], + format="enum-string", + ), +] +RowVatClassificationParam = Annotated[ + VatClassification | dict[str, object] | None, + builder_param( + "Detailed VAT classification for non-standard cases such as WDT, export, exempt, or reverse charge.", + examples=[{"treatment": "zero_wdt", "rate": "0"}], + format="object", + priority="advanced", + schema_ref="ksef2.domain.models.fa3.body.tax.VatClassification", + ), +] +RowUnitOfMeasureParam = Annotated[ + str, + builder_param( + "Unit of measure shown on the invoice line.", + examples=["szt", "h", "kg"], + ), +] +RowSupplyDateParam = Annotated[ + date | None, + builder_param( + "Supply or service date specific to this invoice line.", + examples=["2026-04-08"], + format="date", + priority="advanced", + ), +] +RowDiscountAmountParam = Annotated[ + Decimal | None, + builder_param( + "Discount amount applied to the line.", + examples=["0.00", "15.50"], + format="decimal-string", + priority="advanced", + ), +] +RowSaleCategoryParam = Annotated[ + SaleCategory | str | None, + builder_param( + "Sale category used when the invoice line must distinguish between zero-rated, exempt, reverse-charge, or other sale contexts.", + examples=["rate_23", "zero_wdt", "exempt"], + format="enum-string", + priority="advanced", + ), +] +RowTaxRegimeParam = Annotated[ + TaxRegime | str, + builder_param( + "Tax regime applied to the line, for example the standard regime, margin procedure, or special Title XII procedure.", + examples=["standard", "margin", "special_xii"], + format="enum-string", + priority="advanced", + ), +] +RowOverrideAmountParam = Annotated[ + Decimal | None, + builder_param( + "Explicit line amount. Usually computed automatically; set it only when you intentionally need to override the builder's calculation.", + examples=["100.00", "123.00"], + format="decimal-string", + priority="override", + ), +] +RowVatRateXiiParam = Annotated[ + Decimal | None, + builder_param( + "VAT rate used for special Title XII scenarios.", + examples=["8.50"], + format="decimal-string", + priority="advanced", + ), +] +RowAnnex15Param = Annotated[ + bool | None, + builder_param( + "Set when the line is covered by Annex 15 reporting.", + examples=[True], + priority="advanced", + ), +] +RowExciseAmountParam = Annotated[ + Decimal | None, + builder_param( + "Excise amount linked to the line when required by the invoice scenario.", + examples=["12.30"], + format="decimal-string", + priority="advanced", + ), +] +RowOptionalTextParam = Annotated[ + str | None, + builder_param( + "Optional reference stored for the line.", + examples=["SKU-001"], + priority="advanced", + ), +] +RowGtuCodeParam = Annotated[ + GtuCode | None, + builder_param( + "GTU code assigned to the line when the goods or service falls under GTU reporting.", + examples=["GTU_06"], + format="enum-string", + priority="advanced", + ), +] +RowProcedureParam = Annotated[ + InvoiceProcedure | None, + builder_param( + "Special invoice procedure marker assigned to the line.", + examples=["I_42", "B_SPV"], + format="enum-string", + priority="advanced", + ), +] +RowCurrencyExchangeRateParam = Annotated[ + Decimal | None, + builder_param( + "Currency exchange rate used for this line when the line requires its own rate.", + examples=["4.2512"], + format="decimal-string", + priority="advanced", + ), +] +RowBeforeCorrectionParam = Annotated[ + bool, + builder_param( + "Marks the line as a before-correction value on correction invoices.", + examples=[False], + priority="advanced", + ), +] + + +def _default_state() -> RowsState: + return {"rows": []} + + +def _coerce_vat_rate(vat_rate: VatRate | str | None) -> VatRate | None: + if vat_rate is None or isinstance(vat_rate, VatRate): + return vat_rate + return VatRate(vat_rate) + + +def _coerce_sale_category( + sale_category: SaleCategory | str | None, +) -> SaleCategory | None: + if sale_category is None: + return None + if isinstance(sale_category, SaleCategory): + return sale_category + return SaleCategory(sale_category) + + +def _coerce_tax_regime(tax_regime: TaxRegime | str) -> TaxRegime: + if isinstance(tax_regime, TaxRegime): + return tax_regime + return TaxRegime(tax_regime) + + +def _coerce_vat_classification( + vat_classification: VatClassification | dict[str, object] | None, +) -> VatClassification | None: + return coerce_vat_classification(vat_classification) + + +def _validate_unit_price_choice( + unit_price_net: Decimal | None, + unit_price_gross: Decimal | None, +) -> None: + if unit_price_net is not None and unit_price_gross is not None: + raise ValueError("Provide either unit_price_net or unit_price_gross, not both.") + if unit_price_net is None and unit_price_gross is None: + raise ValueError("Provide unit_price_net or unit_price_gross.") + + +class RowsBuilder[TParent]: + """Fluent builder for FA(3) invoice rows.""" + + def __init__( + self, + parent: TParent, + on_done: Callable[[list[InvoiceRow]], None], + existing_rows: list[InvoiceRow] | None = None, + ) -> None: + self._parent = parent + self._on_done = on_done + self._state: RowsState = adapter.validate_python( + {"rows": list(existing_rows)} if existing_rows else _default_state() + ) + + def from_model(self, rows: list[InvoiceRow]) -> Self: + """Replace the builder state from an existing domain model.""" + self._state = adapter.validate_python({"rows": list(rows)}) + return self + + def add_row( + self, + *, + name: RowNameParam, + quantity: RowQuantityParam, + unit_price_net: RowUnitPriceNetParam = None, + vat_rate: RowVatRateParam = None, + vat_classification: RowVatClassificationParam = None, + unit_of_measure: RowUnitOfMeasureParam = "szt", + supply_date: RowSupplyDateParam = None, + discount_amount: RowDiscountAmountParam = _ZERO_DISCOUNT, + sale_category: RowSaleCategoryParam = None, + tax_regime: RowTaxRegimeParam = TaxRegime.STANDARD, + net_amount: RowOverrideAmountParam = None, + vat_amount: RowOverrideAmountParam = None, + gross_amount: RowOverrideAmountParam = None, + unit_price_gross: RowUnitPriceGrossParam = None, + vat_rate_xii: RowVatRateXiiParam = None, + annex_15_marker: RowAnnex15Param = None, + excise_amount: RowExciseAmountParam = None, + unique_id: RowOptionalTextParam = None, + sku: RowOptionalTextParam = None, + gtin: RowOptionalTextParam = None, + pkwiu: RowOptionalTextParam = None, + cn: RowOptionalTextParam = None, + pkob: RowOptionalTextParam = None, + gtu_code: RowGtuCodeParam = None, + procedure: RowProcedureParam = None, + currency_exchange_rate: RowCurrencyExchangeRateParam = None, + before_correction: RowBeforeCorrectionParam = False, + ) -> Self: + """Add an invoice row from pricing, quantity, and tax fields. + + Raises: + ValueError: If both unit_price_net and unit_price_gross are provided, + or if neither price is provided. + """ + _validate_unit_price_choice(unit_price_net, unit_price_gross) + self._state["rows"].append( + InvoiceRow( + name=name, + quantity=quantity, + unit_price_net=unit_price_net, + vat_rate=_coerce_vat_rate(vat_rate), + vat_classification=_coerce_vat_classification(vat_classification), + unit_of_measure=unit_of_measure, + supply_date=supply_date, + discount_amount=discount_amount, + sale_category=_coerce_sale_category(sale_category), + tax_regime=_coerce_tax_regime(tax_regime), + net_amount=net_amount, + vat_amount=vat_amount, + gross_amount=gross_amount, + unit_price_gross=unit_price_gross, + vat_rate_xii=vat_rate_xii, + annex_15_marker=annex_15_marker, + excise_amount=excise_amount, + unique_id=unique_id, + sku=sku, + gtin=gtin, + pkwiu=pkwiu, + cn=cn, + pkob=pkob, + gtu_code=gtu_code, + procedure=procedure, + currency_exchange_rate=currency_exchange_rate, + before_correction=before_correction, + ) + ) + return self + + def add_line( + self, + *, + name: RowNameParam, + quantity: RowQuantityParam, + unit_price_net: RowUnitPriceNetParam = None, + vat_rate: RowVatRateParam = None, + vat_classification: RowVatClassificationParam = None, + unit_of_measure: RowUnitOfMeasureParam = "szt", + supply_date: RowSupplyDateParam = None, + discount_amount: RowDiscountAmountParam = _ZERO_DISCOUNT, + sale_category: RowSaleCategoryParam = None, + tax_regime: RowTaxRegimeParam = TaxRegime.STANDARD, + net_amount: RowOverrideAmountParam = None, + vat_amount: RowOverrideAmountParam = None, + gross_amount: RowOverrideAmountParam = None, + unit_price_gross: RowUnitPriceGrossParam = None, + vat_rate_xii: RowVatRateXiiParam = None, + annex_15_marker: RowAnnex15Param = None, + excise_amount: RowExciseAmountParam = None, + unique_id: RowOptionalTextParam = None, + sku: RowOptionalTextParam = None, + gtin: RowOptionalTextParam = None, + pkwiu: RowOptionalTextParam = None, + cn: RowOptionalTextParam = None, + pkob: RowOptionalTextParam = None, + gtu_code: RowGtuCodeParam = None, + procedure: RowProcedureParam = None, + currency_exchange_rate: RowCurrencyExchangeRateParam = None, + before_correction: RowBeforeCorrectionParam = False, + ) -> Self: + """Add an invoice line aliasing the row-building API. + + Raises: + ValueError: If both unit_price_net and unit_price_gross are provided, + or if neither price is provided. + """ + return self.add_row( + name=name, + quantity=quantity, + unit_price_net=unit_price_net, + vat_rate=vat_rate, + vat_classification=vat_classification, + unit_of_measure=unit_of_measure, + supply_date=supply_date, + discount_amount=discount_amount, + sale_category=sale_category, + tax_regime=tax_regime, + net_amount=net_amount, + vat_amount=vat_amount, + gross_amount=gross_amount, + unit_price_gross=unit_price_gross, + vat_rate_xii=vat_rate_xii, + annex_15_marker=annex_15_marker, + excise_amount=excise_amount, + unique_id=unique_id, + sku=sku, + gtin=gtin, + pkwiu=pkwiu, + cn=cn, + pkob=pkob, + gtu_code=gtu_code, + procedure=procedure, + currency_exchange_rate=currency_exchange_rate, + before_correction=before_correction, + ) + + def add_row_model(self, row: InvoiceRow) -> Self: + """Add a row model entry.""" + self._state["rows"].append(row) + return self + + def add_line_model(self, line: InvoiceRow) -> Self: + """Add an existing invoice line domain model.""" + self._state["rows"].append(line) + return self + + def replace_lines(self, rows: list[InvoiceRow]) -> Self: + """Replace all invoice lines.""" + self._state["rows"] = list(rows) + return self + + def clear_lines(self) -> Self: + """Remove all invoice lines.""" + self._state["rows"].clear() + return self + + def build(self) -> list[InvoiceRow]: + """Build the corresponding FA(3) domain model.""" + return list(self._state["rows"]) + + def _is_empty(self) -> bool: + return self._state == _default_state() + + def done(self) -> TParent: + """Attach the built rows to the parent builder and return the parent. + + Raises: + ValueError: If no invoice rows have been added. + """ + if self._is_empty(): + raise ValueError("Invoice rows are empty. Add at least one line.") + self._on_done(self.build()) + return self._parent + + +class RowsBuilderMixin: + """Mixin exposing the Rows sub-builder.""" + + _rows: list[InvoiceRow] = [] + + def rows(self) -> RowsBuilder[Self]: + """Start an invoice rows sub-builder.""" + return RowsBuilder(self, self._set_rows, self._rows) + + def _set_rows(self, value: list[InvoiceRow]) -> None: + self._rows = list(value) diff --git a/src/ksef2/services/builders/fa3/sub/settlement.py b/src/ksef2/services/builders/fa3/sub/settlement.py new file mode 100644 index 0000000..4d249b7 --- /dev/null +++ b/src/ksef2/services/builders/fa3/sub/settlement.py @@ -0,0 +1,227 @@ +"""Fluent builder for FA(3) settlement blocks.""" + +from decimal import Decimal +from typing import Annotated, Self, TypedDict +from collections.abc import Callable + +from pydantic import TypeAdapter + +from ksef2.domain.models.fa3 import ( + InvoiceSettlement, + SettlementCharge, + SettlementDeduction, +) +from ksef2.services.builders.fa3.metadata import builder_param + + +class SettlementState(TypedDict): + """Typed state for Settlement fields.""" + + charges: list[SettlementCharge] + charges_total: Decimal | None + deductions: list[SettlementDeduction] + deductions_total: Decimal | None + amount_due: Decimal | None + amount_to_settle: Decimal | None + + +adapter = TypeAdapter(SettlementState) + + +def _default_state() -> SettlementState: + return { + "charges": [], + "charges_total": None, + "deductions": [], + "deductions_total": None, + "amount_due": None, + "amount_to_settle": None, + } + + +class SettlementBuilder[TParent]: + """Fluent builder for FA(3) settlement details.""" + + def __init__( + self, + parent: TParent, + on_done: Callable[[InvoiceSettlement], None], + existing_state: InvoiceSettlement | None = None, + ) -> None: + self._parent = parent + self._on_done = on_done + self._state: SettlementState = adapter.validate_python( + existing_state.model_dump() if existing_state else _default_state() + ) + + def from_model(self, settlement: InvoiceSettlement) -> Self: + """Replace the builder state from an existing domain model.""" + self._state = adapter.validate_python(settlement.model_dump()) + return self + + def add_charge( + self, + *, + amount: Annotated[ + Decimal, + builder_param( + "Additional charge amount included in the settlement.", + examples=["50.00"], + format="decimal-string", + ), + ], + reason: Annotated[ + str, + builder_param( + "Reason for the settlement charge.", + examples=["Delivery surcharge"], + ), + ], + ) -> Self: + """Add a charge entry.""" + self._state["charges"].append(SettlementCharge(amount=amount, reason=reason)) + return self + + def add_charge_model(self, charge: SettlementCharge) -> Self: + """Add an existing settlement charge model.""" + self._state["charges"].append(charge) + return self + + def clear_charges(self) -> Self: + """Remove all settlement charges.""" + self._state["charges"].clear() + self._state["charges_total"] = None + return self + + def charges_total( + self, + amount: Annotated[ + Decimal | None, + builder_param( + "Explicit total of settlement charges when it should be preserved instead of recomputed.", + examples=["50.00"], + format="decimal-string", + priority="override", + ), + ], + ) -> Self: + """Set the charges total value.""" + self._state["charges_total"] = amount + return self + + def add_deduction( + self, + *, + amount: Annotated[ + Decimal, + builder_param( + "Deduction amount included in the settlement.", + examples=["100.00"], + format="decimal-string", + ), + ], + reason: Annotated[ + str, + builder_param( + "Reason for the settlement deduction.", + examples=["Advance paid earlier"], + ), + ], + ) -> Self: + """Add a deduction entry.""" + self._state["deductions"].append( + SettlementDeduction(amount=amount, reason=reason) + ) + return self + + def add_deduction_model(self, deduction: SettlementDeduction) -> Self: + """Add an existing settlement deduction model.""" + self._state["deductions"].append(deduction) + return self + + def clear_deductions(self) -> Self: + """Remove all settlement deductions.""" + self._state["deductions"].clear() + self._state["deductions_total"] = None + return self + + def deductions_total( + self, + amount: Annotated[ + Decimal | None, + builder_param( + "Explicit total of settlement deductions when it should be preserved instead of recomputed.", + examples=["100.00"], + format="decimal-string", + priority="override", + ), + ], + ) -> Self: + """Set the deductions total value.""" + self._state["deductions_total"] = amount + return self + + def amount_due( + self, + amount: Annotated[ + Decimal | None, + builder_param( + "Amount due after charges and deductions are applied.", + examples=["950.00"], + format="decimal-string", + priority="override", + ), + ], + ) -> Self: + """Set the amount due value.""" + self._state["amount_due"] = amount + return self + + def amount_to_settle( + self, + amount: Annotated[ + Decimal | None, + builder_param( + "Remaining amount to settle after taking the settlement context into account.", + examples=["450.00"], + format="decimal-string", + priority="override", + ), + ], + ) -> Self: + """Set the amount to settle value.""" + self._state["amount_to_settle"] = amount + return self + + def build(self) -> InvoiceSettlement: + """Build the corresponding FA(3) domain model.""" + return InvoiceSettlement(**self._state) + + def _is_empty(self) -> bool: + return self._state == _default_state() + + def done(self) -> TParent: + """Attach the built settlement details to the parent builder and return it. + + Raises: + ValueError: If settlement details are empty. + """ + if self._is_empty(): + raise ValueError( + "Settlement details are empty. Set at least one field before calling done()." + ) + self._on_done(self.build()) + return self._parent + + +class SettlementBuilderMixin: + """Mixin exposing the Settlement sub-builder.""" + + _settlement: InvoiceSettlement | None = None + + def settlement(self) -> SettlementBuilder[Self]: + """Start a settlement invoice body builder or sub-builder.""" + return SettlementBuilder(self, self._set_settlement, self._settlement) + + def _set_settlement(self, value: InvoiceSettlement) -> None: + self._settlement = value diff --git a/src/ksef2/services/builders/fa3/sub/transaction.py b/src/ksef2/services/builders/fa3/sub/transaction.py new file mode 100644 index 0000000..33acadf --- /dev/null +++ b/src/ksef2/services/builders/fa3/sub/transaction.py @@ -0,0 +1,432 @@ +"""Fluent builder for FA(3) transaction condition blocks.""" + +from datetime import date, datetime +from decimal import Decimal +from collections.abc import Sequence +from typing import Annotated, Self, TypedDict +from collections.abc import Callable + +from pydantic import TypeAdapter + +from ksef2.domain.models.fa3 import TransactionConditions +from ksef2.domain.models.fa3.body import ( + CargoType, + TransactionAddress, + TransactionContract, + TransactionIdentity, + TransactionOrder, + TransactionTransport, + TransportType, +) +from ksef2.services.builders.fa3.metadata import builder_param + + +class TransactionState(TypedDict): + """Typed state for Transaction fields.""" + + contracts: list[TransactionContract] + orders: list[TransactionOrder] + lot_numbers: list[str] + delivery_terms: str | None + contract_exchange_rate: Decimal | None + contract_currency: str | None + transports: list[TransactionTransport] + intermediary_entity: bool + + +adapter = TypeAdapter(TransactionState) + + +def _default_state() -> TransactionState: + return { + "contracts": [], + "orders": [], + "lot_numbers": [], + "delivery_terms": None, + "contract_exchange_rate": None, + "contract_currency": None, + "transports": [], + "intermediary_entity": False, + } + + +class TransactionBuilder[TParent]: + """Fluent builder for FA(3) transaction conditions.""" + + def __init__( + self, + parent: TParent, + on_done: Callable[[TransactionConditions], None], + existing_state: TransactionConditions | None = None, + ) -> None: + self._parent = parent + self._on_done = on_done + self._state: TransactionState = adapter.validate_python( + existing_state.model_dump() if existing_state else _default_state() + ) + + def from_model(self, transaction: TransactionConditions) -> Self: + """Replace the builder state from an existing domain model.""" + self._state = adapter.validate_python(transaction.model_dump()) + return self + + def delivery_terms( + self, + value: Annotated[ + str | None, + builder_param( + "Delivery terms agreed for the transaction.", + examples=["DAP Berlin", "EXW warehouse"], + priority="advanced", + ), + ], + ) -> Self: + """Set the delivery terms value.""" + self._state["delivery_terms"] = value + return self + + def contract_exchange( + self, + *, + rate: Annotated[ + Decimal | None, + builder_param( + "Contract exchange rate used in the transaction section.", + examples=["4.2512"], + format="decimal-string", + priority="advanced", + ), + ] = None, + currency: Annotated[ + str | None, + builder_param( + "Contract currency used in the transaction section.", + examples=["EUR", "USD"], + priority="advanced", + ), + ] = None, + ) -> Self: + """Set the contract exchange value.""" + self._state["contract_exchange_rate"] = rate + self._state["contract_currency"] = currency + return self + + def intermediary_entity( + self, + enabled: Annotated[ + bool, + builder_param( + "Marks the transaction as involving an intermediary entity.", + examples=[True], + priority="advanced", + ), + ] = True, + ) -> Self: + """Set the intermediary entity value.""" + self._state["intermediary_entity"] = enabled + return self + + def add_contract( + self, + *, + contract_date: Annotated[ + date | None, + builder_param( + "Date of the related contract.", + examples=["2026-04-01"], + format="date", + priority="advanced", + ), + ] = None, + contract_number: Annotated[ + str | None, + builder_param( + "Number of the related contract.", + examples=["CTR/2026/04/01"], + priority="advanced", + ), + ] = None, + ) -> Self: + """Add a contract entry.""" + self._state["contracts"].append( + TransactionContract( + contract_date=contract_date, + contract_number=contract_number, + ) + ) + return self + + def add_contract_model(self, contract: TransactionContract) -> Self: + """Add an existing transaction contract model.""" + self._state["contracts"].append(contract) + return self + + def clear_contracts(self) -> Self: + """Remove all transaction contracts.""" + self._state["contracts"].clear() + return self + + def add_order( + self, + *, + order_date: Annotated[ + date | None, + builder_param( + "Date of the related order.", + examples=["2026-04-02"], + format="date", + priority="advanced", + ), + ] = None, + order_number: Annotated[ + str | None, + builder_param( + "Number of the related order.", + examples=["ORD/2026/04/15"], + priority="advanced", + ), + ] = None, + ) -> Self: + """Add an order reference entry.""" + self._state["orders"].append( + TransactionOrder( + order_date=order_date, + order_number=order_number, + ) + ) + return self + + def add_order_model(self, order: TransactionOrder) -> Self: + """Add an existing transaction order model.""" + self._state["orders"].append(order) + return self + + def clear_orders(self) -> Self: + """Remove all transaction orders.""" + self._state["orders"].clear() + return self + + def add_lot_number( + self, + value: Annotated[ + str, + builder_param( + "Lot or batch number linked to the transaction.", + examples=["LOT-2026-04-01"], + priority="advanced", + ), + ], + ) -> Self: + """Add a lot number entry.""" + self._state["lot_numbers"].append(value) + return self + + def clear_lot_numbers(self) -> Self: + """Remove all lot numbers.""" + self._state["lot_numbers"].clear() + return self + + def add_transport( + self, + *, + transport_type: Annotated[ + TransportType | None, + builder_param( + "Transport type used for the shipment.", + examples=["road", "sea"], + format="enum-string", + priority="advanced", + ), + ] = None, + other_transport: Annotated[ + bool, + builder_param( + "Set to true when the shipment uses a transport type outside the predefined enum.", + examples=[False], + priority="advanced", + ), + ] = False, + other_transport_description: Annotated[ + str | None, + builder_param( + "Free-text description of the transport type when other_transport is enabled.", + examples=["Courier locker delivery"], + priority="advanced", + ), + ] = None, + carrier_identity: Annotated[ + TransactionIdentity | None, + builder_param( + "Carrier identity information stored in the transaction section.", + examples=[], + format="object", + priority="advanced", + schema_ref="ksef2.domain.models.fa3.body.TransactionIdentity", + ), + ] = None, + carrier_address: Annotated[ + TransactionAddress | None, + builder_param( + "Carrier address stored in the transaction section.", + examples=[], + format="object", + priority="advanced", + schema_ref="ksef2.domain.models.fa3.body.TransactionAddress", + ), + ] = None, + transport_order_number: Annotated[ + str | None, + builder_param( + "Transport order number linked to the shipment.", + examples=["TRN/2026/04/22"], + priority="advanced", + ), + ] = None, + cargo_type: Annotated[ + CargoType | None, + builder_param( + "Cargo type used for the shipment.", + examples=["parcel", "bulk"], + format="enum-string", + priority="advanced", + ), + ] = None, + other_cargo: Annotated[ + bool, + builder_param( + "Set to true when the cargo type is described manually instead of using the predefined enum.", + examples=[False], + priority="advanced", + ), + ] = False, + other_cargo_description: Annotated[ + str | None, + builder_param( + "Free-text description of the cargo type when other_cargo is enabled.", + examples=["Mixed electronics"], + priority="advanced", + ), + ] = None, + packaging_unit: Annotated[ + str | None, + builder_param( + "Packaging unit used for the shipment.", + examples=["pallet", "box"], + priority="advanced", + ), + ] = None, + transport_start: Annotated[ + datetime | None, + builder_param( + "Start timestamp of the transport.", + examples=["2026-04-08T08:00:00+02:00"], + format="date-time", + priority="advanced", + ), + ] = None, + transport_end: Annotated[ + datetime | None, + builder_param( + "End timestamp of the transport.", + examples=["2026-04-09T14:30:00+02:00"], + format="date-time", + priority="advanced", + ), + ] = None, + shipping_from: Annotated[ + TransactionAddress | None, + builder_param( + "Shipping origin address.", + examples=[], + format="object", + priority="advanced", + schema_ref="ksef2.domain.models.fa3.body.TransactionAddress", + ), + ] = None, + shipping_via: Annotated[ + Sequence[TransactionAddress] | None, + builder_param( + "Intermediate shipping locations.", + examples=[], + format="object", + priority="advanced", + schema_ref="ksef2.domain.models.fa3.body.TransactionAddress", + ), + ] = None, + shipping_to: Annotated[ + TransactionAddress | None, + builder_param( + "Final shipping destination address.", + examples=[], + format="object", + priority="advanced", + schema_ref="ksef2.domain.models.fa3.body.TransactionAddress", + ), + ] = None, + ) -> Self: + """Add a transport entry.""" + self._state["transports"].append( + TransactionTransport( + transport_type=transport_type, + other_transport=other_transport, + other_transport_description=other_transport_description, + carrier_identity=carrier_identity, + carrier_address=carrier_address, + transport_order_number=transport_order_number, + cargo_type=cargo_type, + other_cargo=other_cargo, + other_cargo_description=other_cargo_description, + packaging_unit=packaging_unit, + transport_start=transport_start, + transport_end=transport_end, + shipping_from=shipping_from, + shipping_via=list(shipping_via or []), + shipping_to=shipping_to, + ) + ) + return self + + def add_transport_model(self, transport: TransactionTransport) -> Self: + """Add an existing transport detail model.""" + self._state["transports"].append(transport) + return self + + def clear_transports(self) -> Self: + """Remove all transport details.""" + self._state["transports"].clear() + return self + + def build(self) -> TransactionConditions: + """Build the corresponding FA(3) domain model.""" + return TransactionConditions(**self._state) + + def _is_empty(self) -> bool: + return self._state == _default_state() + + def done(self) -> TParent: + """Attach the built transaction details to the parent builder and return it. + + Raises: + ValueError: If transaction details are empty. + """ + if self._is_empty(): + raise ValueError( + "Transaction details are empty. Set at least one field before calling done()." + ) + self._on_done(self.build()) + return self._parent + + +class TransactionBuilderMixin: + """Mixin exposing the Transaction sub-builder.""" + + _transaction_conditions: TransactionConditions | None = None + + def transaction(self) -> TransactionBuilder[Self]: + """Start a transaction-conditions sub-builder.""" + return TransactionBuilder( + self, self._set_transaction, self._transaction_conditions + ) + + def _set_transaction(self, value: TransactionConditions) -> None: + self._transaction_conditions = value diff --git a/tests/integration/builders/__init__.py b/tests/integration/builders/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/builders/fa3/__init__.py b/tests/integration/builders/fa3/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/builders/fa3/test_advance_samples.py b/tests/integration/builders/fa3/test_advance_samples.py new file mode 100644 index 0000000..90652a6 --- /dev/null +++ b/tests/integration/builders/fa3/test_advance_samples.py @@ -0,0 +1,889 @@ +from datetime import date, datetime, timezone +from decimal import Decimal + +import pytest +from xsdata.formats.dataclass.parsers import XmlParser + +from ksef2.domain.models.fa3.body import InvoiceSummaryOverrides +from ksef2.domain.models.fa3.party import ContactInfo, InvoiceAddress +from ksef2.domain.models.fa3.third_party import InvoiceThirdParty +from ksef2.infra.mappers.invoices.fa3.spec.invoice import ( + from_spec as invoice_from_spec, +) +from ksef2.infra.schema.fa3.models.schemat import Faktura +from ksef2.services.builders.fa3.root import StandardInvoiceBuilder +from tests.integration.builders.helpers import load_sample, sample_path + + +def _assert_advance_sample(sample_name: str, builder: StandardInvoiceBuilder) -> None: + parser = XmlParser() + expected_spec = load_sample(sample_path(sample_name)) + expected_invoice = invoice_from_spec(expected_spec) + actual_spec = builder.to_spec() + actual_invoice = invoice_from_spec(actual_spec) + xml_invoice = invoice_from_spec( + parser.from_bytes(builder.to_xml().encode("utf-8"), Faktura) + ) + + assert actual_spec.fa.p_2 == expected_spec.fa.p_2 + assert Decimal(actual_spec.fa.p_15) == Decimal(expected_spec.fa.p_15) + assert actual_invoice == expected_invoice + assert xml_invoice == expected_invoice + + +@pytest.mark.integration +def test_new_fa3_advance_builder_sample_10_matches_loaded_sample() -> None: + parser = XmlParser() + expected_spec = load_sample(sample_path("FA_3_Przykład_10.xml")) + expected_invoice = invoice_from_spec(expected_spec) + + builder = StandardInvoiceBuilder() + _ = ( + builder.header( + generation_timestamp=datetime(2026, 2, 15, 9, 30, 47, tzinfo=timezone.utc), + system_info="Samplofaktur", + ) + .seller( + name="ABC Developex sp. z o. o.", + tax_id="9999999999", + country_code="PL", + address_line_1="ul. Sadowa 1 m. 3", + address_line_2="00-002 Kraków", + email="abc@abc.pl", + phone="667444555", + ) + .buyer( + name="F.H.U. Jan Kowalski", + tax_id="1111111111", + country_code="PL", + address_line_1="ul. Polna 1", + address_line_2="00-001 Warszawa", + email="jan@kowalski.pl", + phone="555777999", + ) + ) + + _ = builder.add_third_party_model( + InvoiceThirdParty( + tax_id="3333333333", + name="F.H.U. Grażyna Kowalska", + address=InvoiceAddress( + country_code="PL", + address_line_1="ul. Polna 1", + address_line_2="00-001 Warszawa", + ), + contact=ContactInfo(email="jan@kowalski.pl", phone="555777999"), + role="additional_buyer", + share_percentage=Decimal("50"), + ) + ) + + _ = ( + builder.footer() + .add_information("Kapiał zakładowy 5 000 000") + .add_registry( + krs="0000099999", + regon="999999999", + bdo="000099999", + ) + .done() + ) + + _ = ( + builder.advance() + .currency("PLN") + .issue_date(date(2026, 2, 15)) + .issue_place("Warszawa") + .invoice_number("FZ2026/02/150") + .date_of_supply(date(2026, 2, 15)) + .summary_overrides( + InvoiceSummaryOverrides( + base_rate_net_total=Decimal("16260.16"), + base_rate_vat_total=Decimal("3739.84"), + total_gross=Decimal("20000.00"), + ) + ) + .add_description(key="wysokosć wpłaconego zadatku", value="20000 zł") + .order(declared_total=Decimal("375150.00")) + .add_line( + gross_amount=Decimal("369000.00"), + vat_rate="23", + name="mieszkanie 50m^2", + quantity=Decimal("1"), + unit_of_measure="szt.", + unit_price_net=Decimal("300000.00"), + unique_id="aaaa111133339990", + ) + .add_line( + gross_amount=Decimal("6150.00"), + vat_rate="23", + name="usługi dodatkowe", + quantity=Decimal("1"), + unit_of_measure="szt.", + unit_price_net=Decimal("5000.00"), + unique_id="aaaa111133339991", + ) + .done() + .payment() + .via("bank_transfer") + .already_paid(date(2026, 2, 15)) + .done() + .done() + ) + + actual_spec = builder.to_spec() + actual_invoice = invoice_from_spec(actual_spec) + xml_invoice = invoice_from_spec( + parser.from_bytes(builder.to_xml().encode("utf-8"), Faktura) + ) + + assert actual_spec.fa.p_2 == expected_spec.fa.p_2 + assert Decimal(actual_spec.fa.p_15) == Decimal(expected_spec.fa.p_15) + + assert actual_invoice == expected_invoice + assert xml_invoice == expected_invoice + + +@pytest.mark.integration +def test_new_fa3_correction_advance_builder_sample_12_matches_loaded_sample() -> None: + builder = StandardInvoiceBuilder() + _ = ( + builder.header( + generation_timestamp=datetime(2026, 2, 15, 9, 30, 47, tzinfo=timezone.utc), + system_info="Samplofaktur", + ) + .seller( + name="ABC Developex sp. z o. o.", + tax_id="9999999999", + country_code="PL", + address_line_1="ul. Sadowa 1 m. 3", + address_line_2="00-002 Kraków", + email="abc@abc.pl", + phone="667444555", + ) + .buyer( + name="F.H.U. Jan Kowalski", + tax_id="1111111111", + country_code="PL", + address_line_1="ul. Polna 1", + address_line_2="00-001 Warszawa", + email="jan@kowalski.pl", + phone="555777999", + ) + ) + + _ = builder.add_third_party_model( + InvoiceThirdParty( + tax_id="3333333333", + name="F.H.U. Grażyna Kowalska", + address=InvoiceAddress( + country_code="PL", + address_line_1="ul. Polna 1", + address_line_2="00-001 Warszawa", + ), + contact=ContactInfo(email="jan@kowalski.pl", phone="555777999"), + role="additional_buyer", + share_percentage=Decimal("50"), + ) + ) + + _ = ( + builder.footer() + .add_information("Kapiał zakładowy 5 000 000") + .add_registry( + krs="0000099999", + regon="999999999", + bdo="000099999", + ) + .done() + ) + + body_builder = ( + builder.correction_advance() + .currency("PLN") + .issue_date(date(2026, 3, 17)) + .issue_place("Warszawa") + .invoice_number("FK2026/03/7") + .date_of_supply(date(2026, 2, 15)) + .summary_overrides( + InvoiceSummaryOverrides( + base_rate_net_total=Decimal("133.28"), + base_rate_vat_total=Decimal("30.65"), + first_reduced_rate_net_total=Decimal("9107.47"), + first_reduced_rate_vat_total=Decimal("728.60"), + total_gross=Decimal("10000.00"), + ) + ) + .order(declared_total=Decimal("375150.00")) + .add_line( + gross_amount=Decimal("369000.00"), + vat_rate="8", + name="mieszkanie 50m^2", + quantity=Decimal("1"), + unit_of_measure="szt.", + unit_price_net=Decimal("341666.67"), + unique_id="aaaa111133339990", + before_correction=True, + ) + .add_line( + gross_amount=Decimal("369000.00"), + vat_rate="8", + name="mieszkanie 50m^2", + quantity=Decimal("1"), + unit_of_measure="szt.", + unit_price_net=Decimal("341666.67"), + unique_id="aaaa111133339990", + ) + .add_line( + gross_amount=Decimal("6150.00"), + vat_rate="23", + name="usługi dodatkowe", + quantity=Decimal("1"), + unit_of_measure="szt.", + unit_price_net=Decimal("5000.00"), + unique_id="aaaa111133339991", + before_correction=True, + ) + .add_line( + gross_amount=Decimal("6150.00"), + vat_rate="23", + name="usługi dodatkowe", + quantity=Decimal("1"), + unit_of_measure="szt.", + unit_price_net=Decimal("5000.00"), + unique_id="aaaa111133339991", + ) + .done() + ) + + _ = ( + body_builder.correction() + .reason("błędne zafakturowanie zaniżonej kwoty zadatku 20000 powinno być 30000") + .effect_type("original_entry_date") + .add_corrected_invoice( + issue_date=date(2026, 2, 15), + invoice_number="FZ2026/02/150", + ksef_id="9999999999-20230908-8BEF280C8D35-4D", + ) + .done() + ) + + _ = body_builder.advance().amount_before_correction(Decimal("20000.00")).done() + _ = body_builder.done() + + _assert_advance_sample("FA_3_Przykład_12.xml", builder) + + +@pytest.mark.integration +def test_new_fa3_correction_advance_builder_sample_13_matches_loaded_sample() -> None: + builder = StandardInvoiceBuilder() + _ = ( + builder.header( + generation_timestamp=datetime(2026, 2, 15, 9, 30, 47, tzinfo=timezone.utc), + system_info="Samplofaktur", + ) + .seller( + name="ABC Developex sp. z o. o.", + tax_id="9999999999", + country_code="PL", + address_line_1="ul. Sadowa 1 m. 3", + address_line_2="00-002 Kraków", + email="abc@abc.pl", + phone="667444555", + ) + .buyer( + name="F.H.U. Jan Kowalski", + tax_id="1111111111", + country_code="PL", + address_line_1="ul. Polna 1", + address_line_2="00-001 Warszawa", + email="jan@kowalski.pl", + phone="555777999", + ) + ) + + _ = builder.add_third_party_model( + InvoiceThirdParty( + tax_id="3333333333", + name="F.H.U. Grażyna Kowalska", + address=InvoiceAddress( + country_code="PL", + address_line_1="ul. Polna 1", + address_line_2="00-001 Warszawa", + ), + contact=ContactInfo(email="jan@kowalski.pl", phone="555777999"), + role="additional_buyer", + share_percentage=Decimal("50"), + ) + ) + + _ = ( + builder.footer() + .add_information("Kapiał zakładowy 5 000 000") + .add_registry( + krs="0000099999", + regon="999999999", + bdo="000099999", + ) + .done() + ) + + body_builder = ( + builder.correction_advance() + .currency("PLN") + .issue_date(date(2026, 3, 17)) + .issue_place("Warszawa") + .invoice_number("FK2026/03/7") + .date_of_supply(date(2026, 2, 15)) + .summary_overrides(InvoiceSummaryOverrides(total_gross=Decimal("0.00"))) + .order(declared_total=Decimal("337635.00")) + .add_line( + gross_amount=Decimal("369000.00"), + vat_rate="8", + name="mieszkanie 50m^2", + quantity=Decimal("1"), + unit_of_measure="szt.", + unit_price_net=Decimal("341666.67"), + unique_id="aaaa111133339990", + before_correction=True, + ) + .add_line( + gross_amount=Decimal("332100.00"), + vat_rate="8", + name="mieszkanie 50m^2", + quantity=Decimal("1"), + unit_of_measure="szt.", + unit_price_net=Decimal("307500"), + unique_id="aaaa111133339990", + ) + .add_line( + gross_amount=Decimal("6150.00"), + vat_rate="23", + name="usługi dodatkowe", + quantity=Decimal("1"), + unit_of_measure="szt.", + unit_price_net=Decimal("5000.00"), + unique_id="aaaa111133339991", + before_correction=True, + ) + .add_line( + gross_amount=Decimal("5535.00"), + vat_rate="23", + name="usługi dodatkowe", + quantity=Decimal("1"), + unit_of_measure="szt.", + unit_price_net=Decimal("4500"), + unique_id="aaaa111133339991", + ) + .done() + ) + + _ = ( + body_builder.correction() + .reason("korekta wartości zamówienia o -10%") + .effect_type("other_date") + .add_corrected_invoice( + issue_date=date(2026, 2, 15), + invoice_number="FZ2026/02/150", + ksef_id="9999999999-20230908-8BEF280C8D35-4D", + ) + .done() + ) + + _ = body_builder.advance().amount_before_correction(Decimal("30000.00")).done() + _ = body_builder.done() + + _assert_advance_sample("FA_3_Przykład_13.xml", builder) + + +@pytest.mark.integration +def test_new_fa3_correction_advance_builder_sample_ksef_07_matches_loaded_sample() -> ( + None +): + builder = StandardInvoiceBuilder() + _ = ( + builder.header( + generation_timestamp=datetime(2025, 10, 15, 10, 0, 0, tzinfo=timezone.utc), + system_info="KSEF_TEST_SUITE", + ) + .seller( + name="IMMOBILIA DEVELOPMENT S.A.", + tax_id="9999999999", + country_code="PL", + address_line_1="ul. Developerska 25", + address_line_2="00-100 Warszawa", + email="biuro@immobilia.pl", + phone="+48221112233", + ) + .buyer( + name="Jan Kowalski", + tax_id="1111111111", + country_code="PL", + address_line_1="ul. Mieszkaniowa 10/5", + address_line_2="00-200 Warszawa", + email="j.kowalski@email.pl", + phone="+48600111222", + ) + ) + + _ = builder.add_third_party_model( + InvoiceThirdParty( + tax_id="3333333333", + name="FINANSE PLUS sp. z o.o.", + address=InvoiceAddress( + country_code="PL", + address_line_1="ul. Bankowa 5", + address_line_2="00-300 Warszawa", + ), + contact=ContactInfo(email="kontakt@finanseplus.pl", phone="+48221234000"), + role="additional_buyer", + share_percentage=Decimal("100"), + ) + ) + + _ = ( + builder.footer() + .add_information( + "IMMOBILIA DEVELOPMENT S.A. - Kapital zakladowy: 10 000 000 PLN" + ) + .add_registry( + krs="0000654321", + regon="987654321", + bdo="000054321", + ) + .done() + ) + + body_builder = ( + builder.correction_advance() + .currency("PLN") + .issue_date(date(2025, 10, 15)) + .issue_place("Warszawa") + .invoice_number("FKZ/2025/10/0001") + .date_of_supply(date(2025, 10, 1)) + .summary_overrides( + InvoiceSummaryOverrides( + base_rate_net_total=Decimal("-8130.08"), + base_rate_vat_total=Decimal("-1869.92"), + total_gross=Decimal("-10000.00"), + ) + ) + .order(declared_total=Decimal("406504.07")) + .add_line( + gross_amount=Decimal("500000.00"), + vat_rate="23", + name="Mieszkanie 60m2 - Osiedle Sloneczne, budynek A, lokal 15", + quantity=Decimal("1"), + unit_of_measure="szt.", + unit_price_net=Decimal("406504.07"), + unique_id="KSEF03-ZAM001", + before_correction=True, + ) + .add_line( + gross_amount=Decimal("500000.00"), + vat_rate="23", + name="Mieszkanie 60m2 - Osiedle Sloneczne, budynek A, lokal 15", + quantity=Decimal("1"), + unit_of_measure="szt.", + unit_price_net=Decimal("406504.07"), + unique_id="KSEF03-ZAM001", + ) + .done() + ) + + _ = ( + body_builder.correction() + .reason( + "Czesciowe odstapienie od umowy - zmniejszenie zamowienia o uslugi dodatkowe" + ) + .effect_type("other_date") + .add_corrected_invoice( + issue_date=date(2025, 10, 1), + invoice_number="FZ/2025/10/0001", + ksef_id="9999999999-20251001-A1B2C3-D4E5F6-AB", + ) + .done() + ) + + _ = body_builder.advance().amount_before_correction(Decimal("50000.00")).done() + _ = body_builder.done() + + _assert_advance_sample("KSEF_07_KOR_ZAL_A.xml", builder) + + +@pytest.mark.integration +def test_new_fa3_correction_advance_builder_sample_ksef_08_matches_loaded_sample() -> ( + None +): + builder = StandardInvoiceBuilder() + _ = ( + builder.header( + generation_timestamp=datetime(2025, 10, 20, 11, 0, 0, tzinfo=timezone.utc), + system_info="KSEF_TEST_SUITE", + ) + .seller( + name="IMMOBILIA DEVELOPMENT S.A.", + tax_id="9999999999", + country_code="PL", + address_line_1="ul. Developerska 25", + address_line_2="00-100 Warszawa", + email="biuro@immobilia.pl", + phone="+48221112233", + ) + .buyer( + name="Jan Kowalski", + tax_id="1111111111", + country_code="PL", + address_line_1="ul. Mieszkaniowa 10/5", + address_line_2="00-200 Warszawa", + email="j.kowalski@email.pl", + phone="+48600111222", + ) + ) + + _ = builder.add_third_party_model( + InvoiceThirdParty( + tax_id="3333333333", + name="FINANSE PLUS sp. z o.o.", + address=InvoiceAddress( + country_code="PL", + address_line_1="ul. Bankowa 5", + address_line_2="00-300 Warszawa", + ), + contact=ContactInfo(email="kontakt@finanseplus.pl", phone="+48221234000"), + role="additional_buyer", + share_percentage=Decimal("100"), + ) + ) + + _ = ( + builder.footer() + .add_information( + "IMMOBILIA DEVELOPMENT S.A. - Kapital zakladowy: 10 000 000 PLN" + ) + .add_registry( + krs="0000654321", + regon="987654321", + bdo="000054321", + ) + .done() + ) + + body_builder = ( + builder.correction_advance() + .currency("PLN") + .issue_date(date(2025, 10, 20)) + .issue_place("Warszawa") + .invoice_number("FKZ/2025/10/0002") + .date_of_supply(date(2025, 10, 1)) + .summary_overrides( + InvoiceSummaryOverrides( + base_rate_net_total=Decimal("-32520.33"), + base_rate_vat_total=Decimal("-7479.67"), + first_reduced_rate_net_total=Decimal("37037.04"), + first_reduced_rate_vat_total=Decimal("2962.96"), + total_gross=Decimal("0.00"), + ) + ) + .order(declared_total=Decimal("439000.00")) + .add_line( + gross_amount=Decimal("500000.00"), + vat_rate="23", + name="Mieszkanie 60m2 - Osiedle Sloneczne, budynek A, lokal 15", + quantity=Decimal("1"), + unit_of_measure="szt.", + unit_price_net=Decimal("406504.07"), + unique_id="KSEF03-ZAM001", + before_correction=True, + ) + .add_line( + gross_amount=Decimal("439000.00"), + vat_rate="8", + name="Mieszkanie 60m2 - Osiedle Sloneczne, budynek A, lokal 15", + quantity=Decimal("1"), + unit_of_measure="szt.", + unit_price_net=Decimal("406481.48"), + unique_id="KSEF03-ZAM001", + ) + .done() + ) + + _ = ( + body_builder.correction() + .reason("Bledna stawka VAT - korekta z 23% na 8% (budownictwo spoleczne)") + .effect_type("original_entry_date") + .add_corrected_invoice( + issue_date=date(2025, 10, 1), + invoice_number="FZ/2025/10/0001", + ksef_id="9999999999-20251001-A1B2C3-D4E5F6-AB", + ) + .add_corrected_invoice( + issue_date=date(2025, 10, 15), + invoice_number="FKZ/2025/10/0001", + ksef_id="9999999999-20251015-A1B2C3-D4E5F6-AB", + ) + .done() + ) + + _ = body_builder.advance().amount_before_correction(Decimal("40000.00")).done() + _ = body_builder.done() + + _assert_advance_sample("KSEF_08_KOR_ZAL_B.xml", builder) + + +@pytest.mark.integration +def test_new_fa3_advance_builder_sample_ksef_03_matches_loaded_sample() -> None: + parser = XmlParser() + expected_spec = load_sample(sample_path("KSEF_03_ZAL.xml")) + expected_invoice = invoice_from_spec(expected_spec) + + builder = StandardInvoiceBuilder() + _ = ( + builder.header( + generation_timestamp=datetime(2025, 10, 1, 9, 0, 0, tzinfo=timezone.utc), + system_info="KSEF_TEST_SUITE", + ) + .seller( + name="IMMOBILIA DEVELOPMENT S.A.", + tax_id="9999999999", + country_code="PL", + address_line_1="ul. Developerska 25", + address_line_2="00-100 Warszawa", + email="biuro@immobilia.pl", + phone="+48221112233", + ) + .buyer( + name="Jan Kowalski", + tax_id="1111111111", + country_code="PL", + address_line_1="ul. Mieszkaniowa 10/5", + address_line_2="00-200 Warszawa", + jst_subordinate_unit=False, + vat_group_member=False, + email="j.kowalski@email.pl", + phone="+48600111222", + ) + ) + + _ = builder.add_third_party_model( + InvoiceThirdParty( + tax_id="3333333333", + name="FINANSE PLUS sp. z o.o.", + address=InvoiceAddress( + country_code="PL", + address_line_1="ul. Bankowa 5", + address_line_2="00-300 Warszawa", + ), + contact=ContactInfo(email="kontakt@finanseplus.pl", phone="+48221234000"), + role="additional_buyer", + share_percentage=Decimal("100"), + ) + ) + + _ = ( + builder.footer() + .add_information( + "IMMOBILIA DEVELOPMENT S.A. - Kapital zakladowy: 10 000 000 PLN" + ) + .add_registry( + krs="0000654321", + regon="987654321", + bdo="000054321", + ) + .done() + ) + + _ = ( + builder.advance() + .currency("PLN") + .issue_date(date(2025, 10, 1)) + .issue_place("Warszawa") + .invoice_number("FZ/2025/10/0001") + .date_of_supply(date(2025, 10, 1)) + .summary_overrides( + InvoiceSummaryOverrides( + base_rate_net_total=Decimal("40650.41"), + base_rate_vat_total=Decimal("9349.59"), + total_gross=Decimal("50000.00"), + ) + ) + .add_description(key="Wysokosc wplaconego zadatku", value="50 000 PLN") + .add_description( + key="Umowa przedwstepna", + value="UP/2025/09/0001 z dnia 2025-09-15", + ) + .order(declared_total=Decimal("500000.00")) + .add_line( + gross_amount=Decimal("500000.00"), + vat_rate="23", + name="Mieszkanie 60m2 - Osiedle Sloneczne, budynek A, lokal 15", + quantity=Decimal("1"), + unit_of_measure="szt.", + unit_price_net=Decimal("406504.07"), + unique_id="KSEF03-ZAM001", + ) + .done() + .payment() + .via("bank_transfer") + .already_paid(date(2025, 10, 1)) + .done() + .done() + ) + + actual_spec = builder.to_spec() + actual_invoice = invoice_from_spec(actual_spec) + xml_invoice = invoice_from_spec( + parser.from_bytes(builder.to_xml().encode("utf-8"), Faktura) + ) + + assert actual_spec.fa.p_2 == expected_spec.fa.p_2 + assert Decimal(actual_spec.fa.p_15) == Decimal(expected_spec.fa.p_15) + + assert actual_invoice == expected_invoice + assert xml_invoice == expected_invoice + + +@pytest.mark.integration +def test_new_fa3_correction_advance_builder_sample_11_matches_loaded_sample() -> None: + parser = XmlParser() + expected_spec = load_sample(sample_path("FA_3_Przykład_11.xml")) + expected_invoice = invoice_from_spec(expected_spec) + + builder = StandardInvoiceBuilder() + _ = ( + builder.header( + generation_timestamp=datetime(2026, 2, 15, 9, 30, 47, tzinfo=timezone.utc), + system_info="Samplofaktur", + ) + .seller( + name="ABC Developex sp. z o. o.", + tax_id="9999999999", + country_code="PL", + address_line_1="ul. Sadowa 1 m. 3", + address_line_2="00-002 Kraków", + email="abc@abc.pl", + phone="667444555", + ) + .buyer( + name="F.H.U. Jan Kowalski", + tax_id="1111111111", + country_code="PL", + address_line_1="ul. Polna 1", + address_line_2="00-001 Warszawa", + jst_subordinate_unit=False, + vat_group_member=False, + email="jan@kowalski.pl", + phone="555777999", + ) + ) + + _ = builder.add_third_party_model( + InvoiceThirdParty( + tax_id="3333333333", + name="F.H.U. Grażyna Kowalska", + address=InvoiceAddress( + country_code="PL", + address_line_1="ul. Polna 1", + address_line_2="00-001 Warszawa", + ), + contact=ContactInfo(email="jan@kowalski.pl", phone="555777999"), + role="additional_buyer", + share_percentage=Decimal("50"), + ) + ) + + _ = ( + builder.footer() + .add_information("Kapiał zakładowy 5 000 000") + .add_registry( + krs="0000099999", + regon="999999999", + bdo="000099999", + ) + .done() + ) + + body_builder = ( + builder.correction_advance() + .currency("PLN") + .issue_date(date(2026, 3, 15)) + .issue_place("Warszawa") + .invoice_number("FK2026/03/5") + .date_of_supply(date(2026, 2, 15)) + .summary_overrides( + InvoiceSummaryOverrides( + base_rate_net_total=Decimal("-15993.60"), + base_rate_vat_total=Decimal("-3678.53"), + first_reduced_rate_net_total=Decimal("18214.94"), + first_reduced_rate_vat_total=Decimal("1457.19"), + total_gross=Decimal("0.00"), + ) + ) + .order(declared_total=Decimal("375150.00")) + .add_line( + gross_amount=Decimal("369000.00"), + vat_rate="23", + name="mieszkanie 50m^2", + quantity=Decimal("1"), + unit_of_measure="szt.", + unit_price_net=Decimal("300000.00"), + unique_id="aaaa111133339990", + before_correction=True, + ) + .add_line( + gross_amount=Decimal("369000.00"), + vat_rate="8", + name="mieszkanie 50m^2", + quantity=Decimal("1"), + unit_of_measure="szt.", + unit_price_net=Decimal("341666.67"), + unique_id="aaaa111133339990", + ) + .add_line( + gross_amount=Decimal("6150.00"), + vat_rate="23", + name="usługi dodatkowe", + quantity=Decimal("1"), + unit_of_measure="szt.", + unit_price_net=Decimal("5000.00"), + unique_id="aaaa111133339991", + before_correction=True, + ) + .add_line( + gross_amount=Decimal("6150.00"), + vat_rate="23", + name="usługi dodatkowe", + quantity=Decimal("1"), + unit_of_measure="szt.", + unit_price_net=Decimal("5000.00"), + unique_id="aaaa111133339991", + ) + .done() + ) + + correction_builder = body_builder.correction() + _ = correction_builder.reason("błędna stawka VAT") + _ = correction_builder.effect_type("other_date") + _ = correction_builder.add_corrected_invoice( + issue_date=date(2026, 2, 15), + invoice_number="FZ2026/02/150", + ksef_id="9999999999-20230908-8BEF280C8D35-4D", + ) + _ = correction_builder.done() + + advance_builder = body_builder.advance() + _ = advance_builder.amount_before_correction(Decimal("20000.00")) + _ = advance_builder.done() + _ = body_builder.done() + + actual_spec = builder.to_spec() + actual_invoice = invoice_from_spec(actual_spec) + xml_invoice = invoice_from_spec( + parser.from_bytes(builder.to_xml().encode("utf-8"), Faktura) + ) + + assert actual_spec.fa.p_2 == expected_spec.fa.p_2 + assert Decimal(actual_spec.fa.p_15) == Decimal(expected_spec.fa.p_15) + + assert actual_invoice == expected_invoice + assert xml_invoice == expected_invoice diff --git a/tests/integration/builders/fa3/test_correction_samples.py b/tests/integration/builders/fa3/test_correction_samples.py new file mode 100644 index 0000000..c92b3f2 --- /dev/null +++ b/tests/integration/builders/fa3/test_correction_samples.py @@ -0,0 +1,628 @@ +from datetime import date, datetime, timezone +import pytest +from decimal import Decimal +from xsdata.formats.dataclass.parsers import XmlParser + +from ksef2.domain.models.fa3.body import InvoiceRow, InvoiceSummaryOverrides, VatRate +from ksef2.infra.mappers.invoices.fa3.spec.invoice import ( + from_spec as invoice_from_spec, +) +from ksef2.infra.schema.fa3.models.schemat import Faktura +from ksef2.services.builders.fa3.root import StandardInvoiceBuilder +from tests.integration.builders.helpers import load_sample, sample_path + + +@pytest.mark.integration +def test_new_fa3_correction_builder_sample_2_matches_loaded_sample() -> None: + parser = XmlParser() + expected_spec = load_sample(sample_path("FA_3_Przykład_2.xml")) + expected_invoice = invoice_from_spec(expected_spec) + + builder = StandardInvoiceBuilder() + _ = ( + builder.header( + generation_timestamp=datetime(2026, 2, 1, 0, 0, 0, tzinfo=timezone.utc), + system_info="SamploFaktur", + ) + .seller( + name="ABC AGD sp. z o. o.", + tax_id="9999999999", + country_code="PL", + address_line_1="ul. Kwiatowa 1 m. 2", + address_line_2="00-001 Warszawa", + email="abc@abc.pl", + phone="667444555", + ) + .buyer( + name="F.H.U. Jan Kowalski", + tax_id="1111111111", + country_code="PL", + address_line_1="ul. Polna 1", + address_line_2="00-001 Warszawa", + customer_number="fdfd778343", + email="jan@kowalski.pl", + phone="555777999", + ) + ) + + _ = ( + builder.footer() + .add_information("Kapiał zakładowy 5 000 000") + .add_registry( + krs="0000099999", + regon="999999999", + bdo="000099999", + ) + .done() + ) + + _ = ( + builder.correction() + .issue_date(date(2026, 3, 15)) + .issue_place("Warszawa") + .invoice_number("FK2026/03/200") + .date_of_supply(date(2026, 1, 27)) + .summary_overrides( + InvoiceSummaryOverrides( + base_rate_net_total=Decimal("-162.60"), + base_rate_vat_total=Decimal("-37.40"), + total_gross=Decimal("-200.00"), + ) + ) + .rows() + .add_line( + name="lodówka Zimnotech mk1", + quantity=Decimal("1"), + unit_of_measure="szt.", + unit_price_net=Decimal("1626.01"), + vat_rate=VatRate.VAT_23, + unique_id="aaaa111133339990", + before_correction=True, + ) + .add_line( + name="lodówka Zimnotech mk1", + quantity=Decimal("1"), + unit_of_measure="szt.", + unit_price_net=Decimal("1463.41"), + vat_rate=VatRate.VAT_23, + unique_id="aaaa111133339990", + ) + .done() + .correction() + .reason("obniżka ceny o 200 zł z uwagi na uszkodzenia estetyczne") + .effect_type("other_date") + .add_corrected_invoice( + issue_date=date(2026, 2, 15), + invoice_number="FV2026/02/150", + ksef_id="9999999999-20230908-8BEF280C8D35-4D", + ) + .done() + .done() + ) + + actual_spec = builder.to_spec() + actual_invoice = invoice_from_spec(actual_spec) + xml_invoice = invoice_from_spec( + parser.from_bytes(builder.to_xml().encode("utf-8"), Faktura) + ) + + assert actual_spec.fa.p_2 == expected_spec.fa.p_2 + assert len(actual_spec.fa.fa_wiersz) == len(expected_spec.fa.fa_wiersz) + assert Decimal(actual_spec.fa.p_15) == Decimal(expected_spec.fa.p_15) + + assert actual_invoice == expected_invoice + assert xml_invoice == expected_invoice + + +@pytest.mark.integration +def test_new_fa3_correction_samples_3() -> None: + """FA_3_Przykład_3.xml - correction sample (identical to sample 2)""" + parser = XmlParser() + expected_spec = load_sample(sample_path("FA_3_Przykład_3.xml")) + expected_invoice = invoice_from_spec(expected_spec) + + builder = StandardInvoiceBuilder() + _ = ( + builder.header( + generation_timestamp=datetime(2026, 2, 1, 0, 0, 0, tzinfo=timezone.utc), + system_info="SamploFaktur", + ) + .seller( + name="ABC AGD sp. z o. o.", + tax_id="9999999999", + country_code="PL", + address_line_1="ul. Kwiatowa 1 m. 2", + address_line_2="00-001 Warszawa", + email="abc@abc.pl", + phone="667444555", + ) + .buyer( + name="F.H.U. Jan Kowalski", + tax_id="1111111111", + country_code="PL", + address_line_1="ul. Polna 1", + address_line_2="00-001 Warszawa", + customer_number="fdfd778343", + email="jan@kowalski.pl", + phone="555777999", + ) + ) + + _ = ( + builder.footer() + .add_information("Kapiał zakładowy 5 000 000") + .add_registry( + krs="0000099999", + regon="999999999", + bdo="000099999", + ) + .done() + ) + + _ = ( + builder.correction() + .issue_date(date(2026, 3, 15)) + .issue_place("Warszawa") + .invoice_number("FK2026/03/200") + .date_of_supply(date(2026, 1, 27)) + .summary_overrides( + InvoiceSummaryOverrides( + base_rate_net_total=Decimal("-162.60"), + base_rate_vat_total=Decimal("-37.40"), + total_gross=Decimal("-200.00"), + ) + ) + .rows() + .add_line( + name="lodówka Zimnotech mk1", + quantity=Decimal("1"), + unit_of_measure="szt.", + unit_price_net=Decimal("-162.60"), + vat_rate=VatRate.VAT_23, + unique_id="aaaa111133339990", + ) + .done() + .correction() + .reason("obniżka ceny o 200 zł z uwagi na uszkodzenia estetyczne") + .effect_type("other_date") + .add_corrected_invoice( + issue_date=date(2026, 2, 15), + invoice_number="FV2026/02/150", + ksef_id="9999999999-20230908-8BEF280C8D35-4D", + ) + .done() + .done() + ) + + actual_spec = builder.to_spec() + actual_invoice = invoice_from_spec(actual_spec) + xml_invoice = invoice_from_spec( + parser.from_bytes(builder.to_xml().encode("utf-8"), Faktura) + ) + + assert actual_spec.fa.p_2 == expected_spec.fa.p_2 + assert len(actual_spec.fa.fa_wiersz) == len(expected_spec.fa.fa_wiersz) + assert Decimal(actual_spec.fa.p_15) == Decimal(expected_spec.fa.p_15) + + assert actual_invoice == expected_invoice + assert xml_invoice == expected_invoice + + +@pytest.mark.integration +def test_new_fa3_correction_samples_5() -> None: + """FA_3_Przykład_5.xml - correction with corrected buyer""" + parser = XmlParser() + expected_spec = load_sample(sample_path("FA_3_Przykład_5.xml")) + expected_invoice = invoice_from_spec(expected_spec) + + builder = StandardInvoiceBuilder() + _ = ( + builder.header( + generation_timestamp=datetime(2026, 2, 15, 9, 30, 47, tzinfo=timezone.utc), + system_info="Samplofaktur", + ) + .seller( + name="ABC AGD sp. z o. o.", + tax_id="9999999999", + country_code="PL", + address_line_1="ul. Kwiatowa 1 m. 2", + address_line_2="00-001 Warszawa", + email="abc@abc.pl", + phone="667444555", + ) + .buyer( + name="CeDeE s.c.", + tax_id="1111111111", + country_code="PL", + address_line_1="ul. Sadowa 1 lok. 3", + address_line_2="00-002 Kraków", + customer_number="fdfd778343", + buyer_id="0001", + email="cde@cde.pl", + phone="555777999", + ) + ) + + _ = ( + builder.footer() + .add_information("Kapiał zakładowy 5 000 000") + .add_registry( + krs="0000099999", + regon="999999999", + bdo="000099999", + ) + .done() + ) + + _ = ( + builder.correction() + .issue_date(date(2026, 4, 1)) + .issue_place("Warszawa") + .invoice_number("FK2026/04/23") + .summary_overrides(InvoiceSummaryOverrides(total_gross=Decimal("0.00"))) + .correction() + .reason("błędna nazwa nabywcy") + .effect_type("original_entry_date") + .add_corrected_invoice( + issue_date=date(2026, 2, 15), + invoice_number="FV2026/02/150", + ksef_id="9999999999-20230908-8BEF280C8D35-4D", + ) + .add_corrected_buyer( + name="CDE sp. j.", + tax_id="1111111111", + address_country_code="PL", + address_line_1="ul. Sadowa 1 lok. 3", + address_line_2="00-002 Kraków", + buyer_id="0001", + ) + .done() + .done() + ) + + actual_spec = builder.to_spec() + actual_invoice = invoice_from_spec(actual_spec) + xml_invoice = invoice_from_spec( + parser.from_bytes(builder.to_xml().encode("utf-8"), Faktura) + ) + + assert actual_spec.fa.p_2 == expected_spec.fa.p_2 + assert Decimal(actual_spec.fa.p_15) == Decimal(expected_spec.fa.p_15) + + assert actual_invoice == expected_invoice + assert xml_invoice == expected_invoice + + +@pytest.mark.integration +def test_new_fa3_correction_samples_6() -> None: + """FA_3_Przykład_6.xml - correction with multiple corrected invoices and period""" + parser = XmlParser() + expected_spec = load_sample(sample_path("FA_3_Przykład_6.xml")) + expected_invoice = invoice_from_spec(expected_spec) + + builder = StandardInvoiceBuilder() + _ = ( + builder.header( + generation_timestamp=datetime(2026, 7, 15, 9, 30, 47, tzinfo=timezone.utc), + system_info="Samplofaktur", + ) + .seller( + name="ABC AGD sp. z o. o.", + tax_id="9999999999", + country_code="PL", + address_line_1="ul. Kwiatowa 1 m. 2", + address_line_2="00-001 Warszawa", + email="abc@abc.pl", + phone="667444555", + ) + .buyer( + name="CeDeE s.c.", + tax_id="1111111111", + country_code="PL", + address_line_1="ul. Sadowa 1 lok. 3", + address_line_2="00-002 Kraków", + customer_number="fdfd778343", + email="cde@cde.pl", + phone="555777999", + ) + ) + + _ = ( + builder.footer() + .add_information("Kapiał zakładowy 5 000 000") + .add_registry( + krs="0000099999", + regon="999999999", + bdo="000099999", + ) + .done() + ) + + _ = ( + builder.correction() + .issue_date(date(2026, 7, 15)) + .issue_place("Warszawa") + .invoice_number("FK2026/07/243") + .summary_overrides( + InvoiceSummaryOverrides( + base_rate_net_total=Decimal("-40650.41"), + base_rate_vat_total=Decimal("-9349.59"), + total_gross=Decimal("-50000.00"), + ) + ) + .correction() + .reason("rabat 50000 z uwagi na poziom zakupów pierwszym półroczu 2026") + .effect_type("correction_issue_date") + .corrected_invoice_period("pierwsze półrocze 2026") + .add_corrected_invoice( + issue_date=date(2026, 1, 15), + invoice_number="FV2026/01/134", + ksef_id="9999999999-20230908-8BEF280C8D35-4D", + ) + .add_corrected_invoice( + issue_date=date(2026, 2, 15), + invoice_number="FV2026/02/150", + ksef_id="9999999999-20230908-76B2B580D4DC-80", + ) + .add_corrected_invoice( + issue_date=date(2026, 3, 15), + invoice_number="FV2026/03/143", + ksef_id="9999999999-20230908-4191312C0E57-09", + ) + .add_corrected_invoice( + issue_date=date(2026, 4, 15), + invoice_number="FV2026/04/23", + ksef_id="9999999999-20230908-2B9266CEF3C4-DD", + ) + .add_corrected_invoice( + issue_date=date(2026, 5, 15), + invoice_number="FV2026/05/54", + ksef_id="9999999999-20230908-16B99491C78B-3D", + ) + .add_corrected_invoice( + issue_date=date(2026, 6, 15), + invoice_number="FV2026/06/15", + ksef_id="9999999999-20230908-D08FB95950BE-3E", + ) + .done() + .done() + ) + + actual_spec = builder.to_spec() + actual_invoice = invoice_from_spec(actual_spec) + xml_invoice = invoice_from_spec( + parser.from_bytes(builder.to_xml().encode("utf-8"), Faktura) + ) + + assert actual_spec.fa.p_2 == expected_spec.fa.p_2 + assert Decimal(actual_spec.fa.p_15) == Decimal(expected_spec.fa.p_15) + + assert actual_invoice == expected_invoice + assert xml_invoice == expected_invoice + + +@pytest.mark.integration +def test_new_fa3_correction_samples_7() -> None: + """FA_3_Przykład_7.xml - correction with multiple corrected invoices, period, and descriptive row""" + parser = XmlParser() + expected_spec = load_sample(sample_path("FA_3_Przykład_7.xml")) + expected_invoice = invoice_from_spec(expected_spec) + + builder = StandardInvoiceBuilder() + _ = ( + builder.header( + generation_timestamp=datetime(2026, 7, 15, 9, 30, 47, tzinfo=timezone.utc), + system_info="Samplofaktur", + ) + .seller( + name="ABC AGD sp. z o. o.", + tax_id="9999999999", + country_code="PL", + address_line_1="ul. Kwiatowa 1 m. 2", + address_line_2="00-001 Warszawa", + email="abc@abc.pl", + phone="667444555", + ) + .buyer( + name="CeDeE s.c.", + tax_id="1111111111", + country_code="PL", + address_line_1="ul. Sadowa 1 lok. 3", + address_line_2="00-002 Kraków", + customer_number="fdfd778343", + email="cde@cde.pl", + phone="555777999", + ) + ) + + _ = ( + builder.footer() + .add_information("Kapiał zakładowy 5 000 000") + .add_registry( + krs="0000099999", + regon="999999999", + bdo="000099999", + ) + .done() + ) + + rows_builder = ( + builder.correction() + .issue_date(date(2026, 7, 15)) + .issue_place("Warszawa") + .invoice_number("FK2026/07/243") + .summary_overrides( + InvoiceSummaryOverrides( + base_rate_net_total=Decimal("-40650.41"), + base_rate_vat_total=Decimal("-9349.59"), + total_gross=Decimal("-50000.00"), + ) + ) + .rows() + ) + + _ = rows_builder.add_line_model( + InvoiceRow( + name="lodówka Zimnotech mk1", + quantity=Decimal("1000"), + unit_of_measure="szt.", + cn="8418 21 91", + ) + ) + + _ = ( + rows_builder.done() + .correction() + .reason("rabat 50000 z uwagi na poziom zakupów pierwszym półroczu 2026") + .effect_type("correction_issue_date") + .corrected_invoice_period("pierwsze półrocze 2026") + .add_corrected_invoice( + issue_date=date(2026, 1, 15), + invoice_number="FV2026/01/134", + ksef_id="9999999999-20230908-8BEF280C8D35-4D", + ) + .add_corrected_invoice( + issue_date=date(2026, 2, 15), + invoice_number="FV2026/02/150", + ksef_id="9999999999-20230908-76B2B580D4DC-80", + ) + .add_corrected_invoice( + issue_date=date(2026, 3, 15), + invoice_number="FV2026/03/143", + ksef_id="9999999999-20230908-4191312C0E57-09", + ) + .add_corrected_invoice( + issue_date=date(2026, 4, 15), + invoice_number="FV2026/04/23", + ksef_id="9999999999-20230908-2B9266CEF3C4-DD", + ) + .add_corrected_invoice( + issue_date=date(2026, 5, 15), + invoice_number="FV2026/05/54", + ksef_id="9999999999-20230908-16B99491C78B-3D", + ) + .add_corrected_invoice( + issue_date=date(2026, 6, 15), + invoice_number="FV2026/06/15", + ksef_id="9999999999-20230908-D08FB95950BE-3E", + ) + .done() + .done() + ) + + actual_spec = builder.to_spec() + actual_invoice = invoice_from_spec(actual_spec) + xml_invoice = invoice_from_spec( + parser.from_bytes(builder.to_xml().encode("utf-8"), Faktura) + ) + + assert actual_spec.fa.p_2 == expected_spec.fa.p_2 + assert Decimal(actual_spec.fa.p_15) == Decimal(expected_spec.fa.p_15) + + assert actual_invoice == expected_invoice + assert xml_invoice == expected_invoice + + +@pytest.mark.integration +def test_new_fa3_correction_samples_ksef_02_kor() -> None: + """KSEF_02_KOR.xml - correction with before_correction row""" + parser = XmlParser() + expected_spec = load_sample(sample_path("KSEF_02_KOR.xml")) + expected_invoice = invoice_from_spec(expected_spec) + + builder = StandardInvoiceBuilder() + _ = ( + builder.header( + generation_timestamp=datetime(2025, 12, 20, 14, 0, 0, tzinfo=timezone.utc), + system_info="KSEF_TEST_SUITE", + ) + .seller( + name="TECH-SOLUTIONS sp. z o.o.", + tax_id="9999999999", + country_code="PL", + address_line_1="ul. Marszalkowska 100", + address_line_2="00-001 Warszawa", + email="biuro@tech-solutions.pl", + phone="+48221234567", + ) + .buyer( + name="ABC HANDEL sp. z o.o.", + tax_id="1111111111", + country_code="PL", + address_line_1="ul. Poznanska 50", + address_line_2="60-001 Poznan", + customer_number="ABC-001", + email="kontakt@abc-handel.pl", + phone="+48617654321", + ) + ) + + _ = ( + builder.footer() + .add_information("TECH-SOLUTIONS sp. z o.o. - Kapital zakladowy: 500 000 PLN") + .add_registry( + krs="0000123456", + regon="146025969", + bdo="000012345", + ) + .done() + ) + + _ = ( + builder.correction() + .issue_date(date(2025, 12, 20)) + .issue_place("Warszawa") + .invoice_number("FK/2025/12/0001") + .date_of_supply(date(2025, 12, 10)) + .summary_overrides( + InvoiceSummaryOverrides( + base_rate_net_total=Decimal("-406.50"), + base_rate_vat_total=Decimal("-93.50"), + total_gross=Decimal("-500"), + ) + ) + .rows() + .add_line( + name="Laptop Dell XPS 15", + quantity=Decimal("2"), + unit_of_measure="szt.", + unit_price_net=Decimal("2032.52"), + vat_rate=VatRate.VAT_23, + unique_id="KSEF01-LINE001", + before_correction=True, + ) + .add_line( + name="Laptop Dell XPS 15", + quantity=Decimal("2"), + unit_of_measure="szt.", + unit_price_net=Decimal("1829.27"), + vat_rate=VatRate.VAT_23, + unique_id="KSEF01-LINE001", + ) + .done() + .correction() + .reason( + "Rabat udzielony po dostawie - obnizka ceny o 500 PLN z tytulu promocji" + ) + .effect_type("other_date") + .add_corrected_invoice( + issue_date=date(2025, 12, 15), + invoice_number="FV/2025/12/0001", + ksef_id="9999999999-20251215-A1B2C3-D4E5F6-AB", + ) + .done() + .done() + ) + + actual_spec = builder.to_spec() + actual_invoice = invoice_from_spec(actual_spec) + xml_invoice = invoice_from_spec( + parser.from_bytes(builder.to_xml().encode("utf-8"), Faktura) + ) + + assert actual_spec.fa.p_2 == expected_spec.fa.p_2 + assert len(actual_spec.fa.fa_wiersz) == len(expected_spec.fa.fa_wiersz) + assert Decimal(actual_spec.fa.p_15) == Decimal(expected_spec.fa.p_15) + + assert actual_invoice == expected_invoice + assert xml_invoice == expected_invoice diff --git a/tests/integration/builders/fa3/test_settlement_samples.py b/tests/integration/builders/fa3/test_settlement_samples.py new file mode 100644 index 0000000..7525c21 --- /dev/null +++ b/tests/integration/builders/fa3/test_settlement_samples.py @@ -0,0 +1,764 @@ +from datetime import date, datetime, timezone +import pytest +from decimal import Decimal +from xsdata.formats.dataclass.parsers import XmlParser + +from ksef2.domain.models.fa3.body import InvoiceSummaryOverrides, VatRate +from ksef2.domain.models.fa3.party import ContactInfo, InvoiceAddress +from ksef2.domain.models.fa3.third_party import InvoiceThirdParty +from ksef2.infra.mappers.invoices.fa3.spec.invoice import ( + from_spec as invoice_from_spec, +) +from ksef2.infra.schema.fa3.models.schemat import Faktura +from ksef2.services.builders.fa3.root import StandardInvoiceBuilder +from tests.integration.builders.helpers import load_sample, sample_path + +SETTLEMENT_SAMPLES = [ + "Fa_3_Przykład_17.xml", + "KSEF_10_ROZ_B.xml", +] + +CORRECTION_SETTLEMENT_SAMPLES = [ + "KSEF_11_KOR_ROZ_A.xml", + "KSEF_12_KOR_ROZ_B.xml", +] + + +ALL_SETTLEMENT_SAMPLES = SETTLEMENT_SAMPLES + CORRECTION_SETTLEMENT_SAMPLES + + +@pytest.mark.integration +def test_new_fa3_settlement_builder_sample_ksef_09_matches_loaded_sample() -> None: + parser = XmlParser() + expected_spec = load_sample(sample_path("KSEF_09_ROZ_A.xml")) + expected_invoice = invoice_from_spec(expected_spec) + + builder = StandardInvoiceBuilder() + _ = ( + builder.header( + generation_timestamp=datetime(2025, 11, 15, 9, 0, 0, tzinfo=timezone.utc), + system_info="KSEF_TEST_SUITE", + ) + .seller( + name="IMMOBILIA DEVELOPMENT S.A.", + tax_id="9999999999", + country_code="PL", + address_line_1="ul. Developerska 25", + address_line_2="00-100 Warszawa", + email="biuro@immobilia.pl", + phone="+48221112233", + ) + .buyer( + name="Jan Kowalski", + tax_id="1111111111", + country_code="PL", + address_line_1="ul. Mieszkaniowa 10/5", + address_line_2="00-200 Warszawa", + email="j.kowalski@email.pl", + phone="+48600111222", + ) + ) + + _ = builder.add_third_party_model(expected_invoice.third_parties[0]) + _ = builder.add_third_party_model(expected_invoice.third_parties[1]) + + _ = ( + builder.footer() + .add_information( + "IMMOBILIA DEVELOPMENT S.A. - Kapital zakladowy: 10 000 000 PLN" + ) + .add_registry( + krs="0000654321", + regon="987654321", + bdo="000054321", + ) + .done() + ) + + _ = ( + builder.settlement() + .issue_date(date(2025, 11, 15)) + .issue_place("Warszawa") + .invoice_number("FR/2025/11/0001") + .date_of_supply(date(2025, 11, 30)) + .summary_overrides( + InvoiceSummaryOverrides( + first_reduced_rate_net_total=Decimal("185185.19"), + first_reduced_rate_vat_total=Decimal("14814.81"), + total_gross=Decimal("200000.00"), + ) + ) + .add_description(key="Wartosc zamowienia", value="439 000 PLN") + .add_description(key="Pozostala kwota do zaplaty", value="199 000 PLN") + .rows() + .add_line( + name="Mieszkanie 60m2 - Osiedle Sloneczne, budynek A, lokal 15 - I etap rozliczenia", + quantity=Decimal("1"), + unit_of_measure="szt.", + unit_price_net=Decimal("185185.19"), + vat_rate=VatRate.VAT_8, + unique_id="KSEF09-LINE001", + gtu_code="GTU_10", + ) + .done() + .payment() + .via("bank_transfer") + .due_on(date(2025, 11, 30)) + .due_on(date(2025, 12, 15)) + .bank_account( + "PL61109010140000071219812874", + bank_name="Santander Bank Polska S.A.", + account_description="PLN", + ) + .done() + .advance() + .add_invoice_reference(ksef_id="9999999999-20251001-A1B2C3-D4E5F6-AB") + .done() + .done() + ) + + actual_spec = builder.to_spec() + actual_invoice = invoice_from_spec(actual_spec) + xml_invoice = invoice_from_spec( + parser.from_bytes(builder.to_xml().encode("utf-8"), Faktura) + ) + + assert actual_spec.fa.p_2 == expected_spec.fa.p_2 + assert len(actual_spec.fa.fa_wiersz) == len(expected_spec.fa.fa_wiersz) + assert Decimal(actual_spec.fa.p_15) == Decimal(expected_spec.fa.p_15) + + assert actual_invoice == expected_invoice + assert xml_invoice == expected_invoice + + +@pytest.mark.integration +def test_new_fa3_settlement_builder_sample_14_matches_loaded_sample() -> None: + parser = XmlParser() + expected_spec = load_sample(sample_path("Fa_3_Przykład_14.xml")) + expected_invoice = invoice_from_spec(expected_spec) + + builder = StandardInvoiceBuilder() + _ = ( + builder.header( + generation_timestamp=datetime(2026, 8, 17, 0, 0, 0, tzinfo=timezone.utc), + system_info="Samplofaktur", + ) + .seller( + name="ABC Developex S.A.", + tax_id="9999999999", + country_code="PL", + address_line_1="ul. Sadowa 1 m. 3", + address_line_2="00-002 Kraków", + email="abc@abc.pl", + phone="667444555", + ) + .buyer( + name="F.H.U. Jan Kowalski", + tax_id="1111111111", + country_code="PL", + address_line_1="ul. Polna 1", + address_line_2="00-001 Warszawa", + email="jan@kowalski.pl", + phone="555777999", + ) + ) + + _ = builder.add_third_party_model(expected_invoice.third_parties[0]) + _ = builder.add_third_party_model(expected_invoice.third_parties[1]) + + _ = ( + builder.footer() + .add_information("Kapiał zakładowy 5 000 000") + .add_registry( + krs="0000099999", + regon="999999999", + bdo="000099999", + ) + .done() + ) + + _ = ( + builder.settlement() + .issue_date(date(2026, 8, 17)) + .issue_place("Warszawa") + .invoice_number("FV2026/08/12") + .date_of_supply(date(2026, 9, 17)) + .summary_overrides( + InvoiceSummaryOverrides( + base_rate_net_total=Decimal("4100.16"), + base_rate_vat_total=Decimal("943.04"), + first_reduced_rate_net_total=Decimal("280177.59"), + first_reduced_rate_vat_total=Decimal("22414.21"), + total_gross=Decimal("307635.00"), + ) + ) + .add_description( + key="wysokość pozostałej do zapłaty kwoty", + value="307635 zł", + ) + .add_description( + key="W terminie 2026-09-15", + value="co najmniej 50% pozostałej kwoty", + ) + .add_description( + key="W terminie 2026-10-15", + value="pozostała część", + ) + .rows() + .add_line_model(expected_invoice.body.rows[0]) + .add_line_model(expected_invoice.body.rows[1]) + .done() + .payment() + .via("bank_transfer") + .due_on(date(2026, 9, 15)) + .due_on(date(2026, 10, 15)) + .bank_account( + "73111111111111111111111111", + bank_name="Bank Bankowości Bankowej S. A.", + account_description="PLN", + ) + .done() + .advance() + .add_invoice_reference(ksef_id="9999999999-20230908-8BEF280C8D35-4D") + .done() + .done() + ) + + actual_spec = builder.to_spec() + actual_invoice = invoice_from_spec(actual_spec) + xml_invoice = invoice_from_spec( + parser.from_bytes(builder.to_xml().encode("utf-8"), Faktura) + ) + + assert actual_spec.fa.p_2 == expected_spec.fa.p_2 + assert len(actual_spec.fa.fa_wiersz) == len(expected_spec.fa.fa_wiersz) + assert Decimal(actual_spec.fa.p_15) == Decimal(expected_spec.fa.p_15) + + assert actual_invoice == expected_invoice + assert xml_invoice == expected_invoice + + +@pytest.mark.integration +def test_new_fa3_correction_settlement_builder_sample_18_matches_loaded_sample() -> ( + None +): + parser = XmlParser() + expected_spec = load_sample(sample_path("Fa_3_Przykład_18.xml")) + expected_invoice = invoice_from_spec(expected_spec) + + builder = StandardInvoiceBuilder() + _ = ( + builder.header( + generation_timestamp=datetime(2026, 8, 17, 0, 0, 0, tzinfo=timezone.utc), + system_info="Samplofaktur", + ) + .seller( + name="ABC Developex S.A.", + tax_id="9999999999", + country_code="PL", + address_line_1="ul. Sadowa 1 lok. 3", + address_line_2="00-002 Kraków", + email="abc@abc.pl", + phone="667444555", + ) + .buyer( + name="F.H.U. Jan Kowalski", + tax_id="1111111111", + country_code="PL", + address_line_1="ul. Polna 1", + address_line_2="00-001 Warszawa", + email="jan@kowalski.pl", + phone="555777999", + ) + ) + + _ = builder.add_third_party_model(expected_invoice.third_parties[0]) + + _ = ( + builder.footer() + .add_information("Kapiał zakładowy 5 000 000") + .add_registry( + krs="0000099999", + regon="999999999", + bdo="000099999", + ) + .done() + ) + + _ = ( + builder.correction_settlement() + .issue_date(date(2026, 8, 17)) + .issue_place("Warszawa") + .invoice_number("FK2026/09/1") + .date_of_supply(date(2026, 9, 17)) + .summary_overrides( + InvoiceSummaryOverrides( + base_rate_net_total=Decimal("101.76"), + base_rate_vat_total=Decimal("23.41"), + first_reduced_rate_net_total=Decimal("6953.54"), + first_reduced_rate_vat_total=Decimal("556.29"), + total_gross=Decimal("7635.00"), + ) + ) + .rows() + .add_line_model(expected_invoice.body.rows[0]) + .add_line_model(expected_invoice.body.rows[1]) + .add_line_model(expected_invoice.body.rows[2]) + .add_line_model(expected_invoice.body.rows[3]) + .done() + .correction() + .reason( + "błędne zafakturowanie kwoty pozostałej do zapłaty: było 300000, a powinno być 307635" + ) + .effect_type("original_entry_date") + .add_corrected_invoice( + issue_date=date(2026, 8, 17), + invoice_number="FV2026/08/12", + ksef_id="9999999999-20230908-8BEF280C8D35-4D", + ) + .done() + .advance() + .amount_before_correction(Decimal("300000.00")) + .add_invoice_reference(ksef_id="9999999999-20230908-76B2B580D4DC-80") + .done() + .done() + ) + + actual_spec = builder.to_spec() + actual_invoice = invoice_from_spec(actual_spec) + xml_invoice = invoice_from_spec( + parser.from_bytes(builder.to_xml().encode("utf-8"), Faktura) + ) + + assert actual_spec.fa.p_2 == expected_spec.fa.p_2 + assert len(actual_spec.fa.fa_wiersz) == len(expected_spec.fa.fa_wiersz) + assert Decimal(actual_spec.fa.p_15) == Decimal(expected_spec.fa.p_15) + + assert actual_invoice == expected_invoice + assert xml_invoice == expected_invoice + + +def _assert_sample(builder: StandardInvoiceBuilder, sample_name: str) -> None: + parser = XmlParser() + expected_spec = load_sample(sample_path(sample_name)) + expected_invoice = invoice_from_spec(expected_spec) + actual_spec = builder.to_spec() + actual_invoice = invoice_from_spec(actual_spec) + xml_invoice = invoice_from_spec( + parser.from_bytes(builder.to_xml().encode("utf-8"), Faktura) + ) + + assert actual_spec.fa.p_2 == expected_spec.fa.p_2 + assert len(actual_spec.fa.fa_wiersz) == len(expected_spec.fa.fa_wiersz) + assert Decimal(actual_spec.fa.p_15) == Decimal(expected_spec.fa.p_15) + assert actual_invoice == expected_invoice + assert xml_invoice == expected_invoice + + +@pytest.mark.integration +def test_new_fa3_settlement_sample_17_manual() -> None: + builder = StandardInvoiceBuilder() + _ = ( + builder.header( + generation_timestamp=datetime(2026, 8, 17, 0, 0, 0, tzinfo=timezone.utc), + system_info="Samplofaktur", + ) + .seller( + name="ABC Developex S.A.", + tax_id="9999999999", + country_code="PL", + address_line_1="ul. Sadowa 1 lok. 3", + address_line_2="00-002 Kraków", + email="abc@abc.pl", + phone="667444555", + ) + .buyer( + name="F.H.U. Jan Kowalski", + tax_id="1111111111", + country_code="PL", + address_line_1="ul. Polna 1", + address_line_2="00-001 Warszawa", + email="jan@kowalski.pl", + phone="555777999", + ) + ) + _ = builder.add_third_party_model( + InvoiceThirdParty( + tax_id="3333333333", + name="F.H.U. Grażyna Kowalska", + address=InvoiceAddress( + country_code="PL", + address_line_1="ul. Polna 1", + address_line_2="00-001 Warszawa", + ), + contact=ContactInfo(email="jan@kowalski.pl", phone="555777999"), + role="additional_buyer", + share_percentage=Decimal("50"), + ) + ) + _ = ( + builder.footer() + .add_information("Kapiał zakładowy 5 000 000") + .add_registry(krs="0000099999", regon="999999999", bdo="000099999") + .done() + ) + _ = ( + builder.settlement() + .issue_date(date(2026, 8, 17)) + .issue_place("Warszawa") + .invoice_number("FV2026/08/12") + .date_of_supply(date(2026, 9, 17)) + .summary_overrides( + InvoiceSummaryOverrides( + base_rate_net_total=Decimal("3998.40"), + base_rate_vat_total=Decimal("919.63"), + first_reduced_rate_net_total=Decimal("273224.05"), + first_reduced_rate_vat_total=Decimal("21857.92"), + total_gross=Decimal("300000"), + ) + ) + .add_description(key="wysokosć pozostałej do zapłaty kwoty", value="300000 zł") + .rows() + .add_line( + name="mieszkanie 50m^2", + quantity=Decimal("1"), + unit_of_measure="szt.", + unit_price_net=Decimal("307500"), + vat_rate=VatRate.VAT_8, + unique_id="aaaa111133339997", + gtu_code="GTU_10", + ) + .add_line( + name="usługi dodatkowe", + quantity=Decimal("1"), + unit_of_measure="szt.", + unit_price_net=Decimal("4500"), + vat_rate=VatRate.VAT_23, + unique_id="aaaa111133339998", + ) + .done() + .advance() + .add_invoice_reference(ksef_id="9999999999-20230908-8BEF280C8D35-4D") + .done() + .done() + ) + + _assert_sample(builder, "Fa_3_Przykład_17.xml") + + +@pytest.mark.integration +def test_new_fa3_settlement_sample_ksef_10_manual() -> None: + builder = StandardInvoiceBuilder() + _ = ( + builder.header( + generation_timestamp=datetime(2025, 12, 20, 10, 0, 0, tzinfo=timezone.utc), + system_info="KSEF_TEST_SUITE", + ) + .seller( + name="IMMOBILIA DEVELOPMENT S.A.", + tax_id="9999999999", + country_code="PL", + address_line_1="ul. Developerska 25", + address_line_2="00-100 Warszawa", + email="biuro@immobilia.pl", + phone="+48221112233", + ) + .buyer( + name="Jan Kowalski", + tax_id="1111111111", + country_code="PL", + address_line_1="ul. Mieszkaniowa 10/5", + address_line_2="00-200 Warszawa", + email="j.kowalski@email.pl", + phone="+48600111222", + ) + ) + _ = builder.add_third_party_model( + InvoiceThirdParty( + tax_id="3333333333", + name="FINANSE PLUS sp. z o.o.", + address=InvoiceAddress( + country_code="PL", + address_line_1="ul. Bankowa 5", + address_line_2="00-300 Warszawa", + ), + contact=ContactInfo(email="kontakt@finanseplus.pl", phone="+48221234000"), + role="additional_buyer", + share_percentage=Decimal("100"), + ) + ) + _ = builder.add_third_party_model( + InvoiceThirdParty( + tax_id="9999999999", + name="IMMOBILIA DEVELOPMENT S.A.", + address=InvoiceAddress( + country_code="PL", + address_line_1="ul. Developerska 25", + address_line_2="00-100 Warszawa", + ), + contact=ContactInfo(email="biuro@immobilia.pl", phone="+48221112233"), + role="original_subject", + ) + ) + _ = ( + builder.footer() + .add_information( + "IMMOBILIA DEVELOPMENT S.A. - Kapital zakladowy: 10 000 000 PLN" + ) + .add_registry(krs="0000654321", regon="987654321", bdo="000054321") + .done() + ) + _ = ( + builder.settlement() + .issue_date(date(2025, 12, 20)) + .issue_place("Warszawa") + .invoice_number("FR/2025/12/0001") + .date_of_supply(date(2025, 12, 20)) + .summary_overrides( + InvoiceSummaryOverrides( + first_reduced_rate_net_total=Decimal("184259.26"), + first_reduced_rate_vat_total=Decimal("14740.74"), + total_gross=Decimal("199000"), + ) + ) + .add_description(key="Wartosc calkowita zamowienia", value="439 000 PLN") + .add_description( + key="Status", value="ROZLICZENIE KONCOWE - Calkowicie oplacone" + ) + .rows() + .add_line( + name="Mieszkanie 60m2 - Osiedle Sloneczne, budynek A, lokal 15 - ROZLICZENIE KONCOWE", + quantity=Decimal("1"), + unit_of_measure="szt.", + unit_price_net=Decimal("184259.26"), + vat_rate=VatRate.VAT_8, + unique_id="KSEF10-LINE001", + gtu_code="GTU_10", + ) + .done() + .payment() + .via("bank_transfer") + .already_paid(date(2025, 12, 20)) + .bank_account( + "PL61109010140000071219812874", + bank_name="Santander Bank Polska S.A.", + account_description="PLN", + ) + .done() + .advance() + .add_invoice_reference(ksef_id="9999999999-20251001-A1B2C3-D4E5F6-AB") + .add_invoice_reference(ksef_id="9999999999-20251115-A1B2C3-D4E5F6-AB") + .done() + .done() + ) + + _assert_sample(builder, "KSEF_10_ROZ_B.xml") + + +@pytest.mark.integration +def test_new_fa3_correction_settlement_sample_ksef_11_manual() -> None: + builder = StandardInvoiceBuilder() + _ = ( + builder.header( + generation_timestamp=datetime(2025, 11, 25, 14, 0, 0, tzinfo=timezone.utc), + system_info="KSEF_TEST_SUITE", + ) + .seller( + name="IMMOBILIA DEVELOPMENT S.A.", + tax_id="9999999999", + country_code="PL", + address_line_1="ul. Developerska 25", + address_line_2="00-100 Warszawa", + email="biuro@immobilia.pl", + phone="+48221112233", + ) + .buyer( + name="Jan Kowalski", + tax_id="1111111111", + country_code="PL", + address_line_1="ul. Mieszkaniowa 10/5", + address_line_2="00-200 Warszawa", + email="j.kowalski@email.pl", + phone="+48600111222", + ) + ) + _ = builder.add_third_party_model( + InvoiceThirdParty( + tax_id="3333333333", + name="FINANSE PLUS sp. z o.o.", + address=InvoiceAddress( + country_code="PL", + address_line_1="ul. Bankowa 5", + address_line_2="00-300 Warszawa", + ), + contact=ContactInfo(email="kontakt@finanseplus.pl", phone="+48221234000"), + role="additional_buyer", + share_percentage=Decimal("100"), + ) + ) + _ = ( + builder.footer() + .add_information( + "IMMOBILIA DEVELOPMENT S.A. - Kapital zakladowy: 10 000 000 PLN" + ) + .add_registry(krs="0000654321", regon="987654321", bdo="000054321") + .done() + ) + _ = ( + builder.correction_settlement() + .issue_date(date(2025, 11, 25)) + .issue_place("Warszawa") + .invoice_number("FKR/2025/11/0001") + .date_of_supply(date(2025, 11, 30)) + .summary_overrides( + InvoiceSummaryOverrides( + first_reduced_rate_net_total=Decimal("-9259.26"), + first_reduced_rate_vat_total=Decimal("-740.74"), + total_gross=Decimal("-10000"), + ) + ) + .rows() + .add_line( + name="Mieszkanie 60m2 - Osiedle Sloneczne, budynek A, lokal 15 - I etap rozliczenia", + quantity=Decimal("1"), + unit_of_measure="szt.", + unit_price_net=Decimal("185185.19"), + vat_rate=VatRate.VAT_8, + unique_id="KSEF09-LINE001", + gtu_code="GTU_10", + before_correction=True, + ) + .add_line( + name="Mieszkanie 60m2 - Osiedle Sloneczne, budynek A, lokal 15 - I etap rozliczenia", + quantity=Decimal("1"), + unit_of_measure="szt.", + unit_price_net=Decimal("175925.93"), + vat_rate=VatRate.VAT_8, + unique_id="KSEF09-LINE001", + gtu_code="GTU_10", + ) + .done() + .correction() + .reason( + "Blad w obliczeniach - niezastosowana znizka 5% za wczesniejsza platnosc" + ) + .effect_type("original_entry_date") + .add_corrected_invoice( + issue_date=date(2025, 11, 15), + invoice_number="FR/2025/11/0001", + ksef_id="9999999999-20251115-A1B2C3-D4E5F6-AB", + ) + .done() + .advance() + .amount_before_correction(Decimal("200000")) + .add_invoice_reference(ksef_id="9999999999-20251001-A1B2C3-D4E5F6-AB") + .done() + .done() + ) + + _assert_sample(builder, "KSEF_11_KOR_ROZ_A.xml") + + +@pytest.mark.integration +def test_new_fa3_correction_settlement_sample_ksef_12_manual() -> None: + builder = StandardInvoiceBuilder() + _ = ( + builder.header( + generation_timestamp=datetime(2025, 12, 28, 15, 30, 0, tzinfo=timezone.utc), + system_info="KSEF_TEST_SUITE", + ) + .seller( + name="IMMOBILIA DEVELOPMENT S.A.", + tax_id="9999999999", + country_code="PL", + address_line_1="ul. Developerska 25", + address_line_2="00-100 Warszawa", + email="biuro@immobilia.pl", + phone="+48221112233", + ) + .buyer( + name="Jan Kowalski", + tax_id="1111111111", + country_code="PL", + address_line_1="ul. Mieszkaniowa 10/5", + address_line_2="00-200 Warszawa", + email="j.kowalski@email.pl", + phone="+48600111222", + ) + ) + _ = builder.add_third_party_model( + InvoiceThirdParty( + tax_id="3333333333", + name="FINANSE PLUS sp. z o.o.", + address=InvoiceAddress( + country_code="PL", + address_line_1="ul. Bankowa 5", + address_line_2="00-300 Warszawa", + ), + contact=ContactInfo(email="kontakt@finanseplus.pl", phone="+48221234000"), + role="additional_buyer", + share_percentage=Decimal("100"), + ) + ) + _ = ( + builder.footer() + .add_information( + "IMMOBILIA DEVELOPMENT S.A. - Kapital zakladowy: 10 000 000 PLN" + ) + .add_registry(krs="0000654321", regon="987654321", bdo="000054321") + .done() + ) + _ = ( + builder.correction_settlement() + .issue_date(date(2025, 12, 28)) + .issue_place("Warszawa") + .invoice_number("FKR/2025/12/0001") + .date_of_supply(date(2025, 12, 20)) + .summary_overrides( + InvoiceSummaryOverrides( + first_reduced_rate_net_total=Decimal("-4629.63"), + first_reduced_rate_vat_total=Decimal("-370.37"), + total_gross=Decimal("-5000"), + ) + ) + .rows() + .add_line( + name="Mieszkanie 60m2 - Osiedle Sloneczne, budynek A, lokal 15 - ROZLICZENIE KONCOWE", + quantity=Decimal("1"), + unit_of_measure="szt.", + unit_price_net=Decimal("184259.26"), + vat_rate=VatRate.VAT_8, + unique_id="KSEF10-LINE001", + gtu_code="GTU_10", + before_correction=True, + ) + .add_line( + name="Mieszkanie 60m2 - Osiedle Sloneczne, budynek A, lokal 15 - ROZLICZENIE KONCOWE (po korekcie)", + quantity=Decimal("1"), + unit_of_measure="szt.", + unit_price_net=Decimal("179629.63"), + vat_rate=VatRate.VAT_8, + unique_id="KSEF10-LINE001", + gtu_code="GTU_10", + ) + .done() + .correction() + .reason( + "Wada wykonczenia stwierdzona przy odbiorze - rysy na parkiecie w salonie" + ) + .effect_type("other_date") + .add_corrected_invoice( + issue_date=date(2025, 12, 20), + invoice_number="FR/2025/12/0001", + ksef_id="9999999999-20251220-A1B2C3-D4E5F6-AB", + ) + .done() + .advance() + .amount_before_correction(Decimal("199000")) + .add_invoice_reference(ksef_id="9999999999-20251001-A1B2C3-D4E5F6-AB") + .add_invoice_reference(ksef_id="9999999999-20251115-A1B2C3-D4E5F6-AB") + .done() + .done() + ) + + _assert_sample(builder, "KSEF_12_KOR_ROZ_B.xml") diff --git a/tests/integration/builders/fa3/test_simplified_samples.py b/tests/integration/builders/fa3/test_simplified_samples.py new file mode 100644 index 0000000..23f2d96 --- /dev/null +++ b/tests/integration/builders/fa3/test_simplified_samples.py @@ -0,0 +1,163 @@ +import pytest +from datetime import date, datetime, timezone +from decimal import Decimal +from xsdata.formats.dataclass.parsers import XmlParser + +from ksef2.domain.models.fa3.body import InvoiceRow, InvoiceSummaryOverrides, VatRate +from ksef2.infra.mappers.invoices.fa3.spec.invoice import ( + from_spec as invoice_from_spec, +) +from ksef2.infra.schema.fa3.models.schemat import Faktura +from tests.integration.builders.helpers import load_sample, sample_path +from ksef2.services.builders.fa3.root import StandardInvoiceBuilder + + +def _assert_sample(builder: StandardInvoiceBuilder, sample_name: str) -> None: + parser = XmlParser() + expected = load_sample(sample_path(sample_name)) + actual = builder.to_spec() + actual_invoice = invoice_from_spec(actual) + expected_invoice = invoice_from_spec(expected) + xml_invoice = invoice_from_spec( + parser.from_bytes(builder.to_xml().encode("utf-8"), Faktura) + ) + + assert actual.fa.p_2 == expected.fa.p_2 + assert Decimal(actual.fa.p_15) == Decimal(expected.fa.p_15) + assert len(actual.fa.fa_wiersz) == len(expected.fa.fa_wiersz) + assert actual_invoice == expected_invoice + assert xml_invoice == expected_invoice + + +@pytest.mark.integration +def test_new_fa3_simplified_sample_15() -> None: + builder = StandardInvoiceBuilder() + _ = ( + builder.header( + generation_timestamp=datetime(2026, 2, 1, 0, 0, 0, tzinfo=timezone.utc), + system_info="SamploFaktur", + ) + .seller( + name="ABC AGD sp. z o. o.", + tax_id="9999999999", + country_code="PL", + address_line_1="ul. Kwiatowa 1 m. 2", + address_line_2="00-001 Warszawa", + email="abc@abc.pl", + phone="667444555", + ) + .buyer(name=None, tax_id="1111111111", country_code=None, address_line_1=None) + ) + _ = ( + builder.footer() + .add_information("Kapiał zakładowy 5 000 000") + .add_registry(krs="0000099999", regon="999999999", bdo="000099999") + .done() + ) + _ = ( + builder.simplified() + .issue_date(date(2026, 2, 15)) + .invoice_number("FV2026/02/150") + .date_of_supply(date(2026, 1, 3)) + .summary_overrides( + InvoiceSummaryOverrides( + base_rate_net_total=Decimal("365.85"), + base_rate_vat_total=Decimal("84.15"), + total_gross=Decimal("450"), + ) + ) + .rows() + .add_line_model(InvoiceRow(name="wiertarka Wiertex mk5")) + .done() + .done() + ) + + _assert_sample(builder, "FA_3_Przykład_15.xml") + + +@pytest.mark.integration +def test_new_fa3_simplified_sample_16() -> None: + builder = StandardInvoiceBuilder() + _ = ( + builder.header( + generation_timestamp=datetime(2026, 2, 1, 0, 0, 0, tzinfo=timezone.utc), + system_info="SamploFaktur", + ) + .seller( + name="ABC AGD sp. z o. o.", + tax_id="9999999999", + country_code="PL", + address_line_1="ul. Kwiatowa 1 m. 2", + address_line_2="00-001 Warszawa", + email="abc@abc.pl", + phone="667444555", + ) + .buyer(name=None, tax_id="1111111111", country_code=None, address_line_1=None) + ) + _ = ( + builder.footer() + .add_information("Kapiał zakładowy 5 000 000") + .add_registry(krs="0000099999", regon="999999999", bdo="000099999") + .done() + ) + _ = ( + builder.simplified() + .issue_date(date(2026, 2, 15)) + .invoice_number("FV2026/02/150") + .date_of_supply(date(2026, 1, 3)) + .summary_overrides(InvoiceSummaryOverrides(total_gross=Decimal("450"))) + .rows() + .add_line_model( + InvoiceRow(name="wiertarka Wiertex mk5", vat_rate=VatRate.VAT_23) + ) + .done() + .done() + ) + + _assert_sample(builder, "FA_3_Przykład_16.xml") + + +@pytest.mark.integration +def test_new_fa3_simplified_sample_ksef_04_upr() -> None: + builder = StandardInvoiceBuilder() + _ = ( + builder.header( + generation_timestamp=datetime(2025, 12, 5, 11, 30, 0, tzinfo=timezone.utc), + system_info="KSEF_TEST_SUITE", + ) + .seller( + name="SKLEP NAROZNY sp. z o.o.", + tax_id="9999999999", + country_code="PL", + address_line_1="ul. Handlowa 15", + address_line_2="00-400 Warszawa", + email="sklep@narozny.pl", + phone="+48223334455", + ) + .buyer(name=None, tax_id="1111111111", country_code=None, address_line_1=None) + ) + _ = ( + builder.footer() + .add_information("SKLEP NAROZNY sp. z o.o.") + .add_registry(regon="111222333") + .done() + ) + _ = ( + builder.simplified() + .issue_date(date(2025, 12, 5)) + .invoice_number("FU/2025/12/0001") + .date_of_supply(date(2025, 12, 5)) + .summary_overrides( + InvoiceSummaryOverrides( + base_rate_net_total=Decimal("300.00"), + base_rate_vat_total=Decimal("69.00"), + total_gross=Decimal("369"), + ) + ) + .rows() + .add_line_model(InvoiceRow(name="Artykuly biurowe (zestaw)")) + .done() + .done() + ) + + _assert_sample(builder, "KSEF_04_UPR.xml") diff --git a/tests/integration/builders/fa3/test_standard_invoice.py b/tests/integration/builders/fa3/test_standard_invoice.py new file mode 100644 index 0000000..bca5358 --- /dev/null +++ b/tests/integration/builders/fa3/test_standard_invoice.py @@ -0,0 +1,126 @@ +from datetime import date, datetime, timezone +from decimal import Decimal + +import pytest +from xsdata.formats.dataclass.parsers import XmlParser + +from ksef2.domain.models.fa3.body import InvoiceSummaryOverrides, VatRate +from ksef2.infra.mappers.invoices.fa3.spec.invoice import from_spec as invoice_from_spec +from ksef2.infra.schema.fa3.models.schemat import Faktura +from ksef2.services.builders.fa3.root import StandardInvoiceBuilder +from tests.integration.builders.helpers import load_sample, sample_path + + +@pytest.mark.integration +def test_new_fa3_standard_builder_sample_1_matches_loaded_sample() -> None: + parser = XmlParser() + expected_spec = load_sample(sample_path("FA_3_Przykład_1.xml")) + expected_invoice = invoice_from_spec(expected_spec) + + builder = StandardInvoiceBuilder() + _ = ( + builder.header( + generation_timestamp=datetime(2026, 2, 1, 0, 0, 0, tzinfo=timezone.utc), + system_info="SamploFaktur", + ) + .seller( + name="ABC AGD sp. z o. o.", + tax_id="9999999999", + country_code="PL", + address_line_1="ul. Kwiatowa 1 m. 2", + address_line_2="00-001 Warszawa", + email="abc@abc.pl", + phone="667444555", + ) + .buyer( + name="F.H.U. Jan Kowalski", + tax_id="1111111111", + country_code="PL", + address_line_1="ul. Polna 1", + address_line_2="00-001 Warszawa", + customer_number="fdfd778343", + email="jan@kowalski.pl", + phone="555777999", + ) + ) + + _ = ( + builder.footer() + .add_information("Kapiał zakładowy 5 000 000") + .add_registry( + krs="0000099999", + regon="999999999", + bdo="000099999", + ) + .done() + ) + + _ = ( + builder.standard() + .issue_date(date(2026, 2, 15)) + .issue_place("Warszawa") + .invoice_number("FV2026/02/150") + .date_of_supply(date(2026, 1, 27)) + .mark_fp() + .summary_overrides( + InvoiceSummaryOverrides( + base_rate_net_total=Decimal("1666.66"), + base_rate_vat_total=Decimal("383.33"), + second_reduced_rate_net_total=Decimal("0.95"), + second_reduced_rate_vat_total=Decimal("0.05"), + total_gross=Decimal("2051.00"), + ) + ) + .add_description( + key="preferowane godziny dowozu", + value="dni robocze 17:00 - 20:00", + ) + .rows() + .add_line( + name="lodówka Zimnotech mk1", + quantity=Decimal("1"), + unit_of_measure="szt.", + unit_price_net=Decimal("1626.01"), + vat_rate=VatRate.VAT_23, + unique_id="aaaa111133339990", + ) + .add_line( + name="wniesienie sprzętu", + quantity=Decimal("1"), + unit_of_measure="szt.", + unit_price_net=Decimal("40.65"), + vat_rate=VatRate.VAT_23, + unique_id="aaaa111133339991", + ) + .add_line( + name="promocja lodówka pełna mleka", + quantity=Decimal("1"), + unit_of_measure="szt.", + unit_price_net=Decimal("0.95"), + vat_rate=VatRate.VAT_5, + unique_id="aaaa111133339992", + ) + .done() + .payment() + .via("bank_transfer") + .already_paid(date(2026, 1, 27)) + .done() + .transaction() + .add_order(order_date=date(2026, 1, 26), order_number="4354343") + .done() + .done() + ) + + actual_spec = builder.to_spec() + actual_invoice = invoice_from_spec(actual_spec) + xml_invoice = invoice_from_spec( + parser.from_bytes(builder.to_xml().encode("utf-8"), Faktura) + ) + + assert actual_spec.fa.p_2 == expected_spec.fa.p_2 + assert Decimal(actual_spec.fa.p_15) == Decimal(expected_spec.fa.p_15) + assert actual_spec.fa.fa_wiersz[0].p_7 == expected_spec.fa.fa_wiersz[0].p_7 + assert len(actual_spec.fa.fa_wiersz) == len(expected_spec.fa.fa_wiersz) + + assert actual_invoice == expected_invoice + assert xml_invoice == expected_invoice diff --git a/tests/integration/builders/fa3/test_vat_samples.py b/tests/integration/builders/fa3/test_vat_samples.py new file mode 100644 index 0000000..a5a0a98 --- /dev/null +++ b/tests/integration/builders/fa3/test_vat_samples.py @@ -0,0 +1,580 @@ +from decimal import Decimal +from datetime import date, datetime, timezone + +import pytest +from xsdata.formats.dataclass.parsers import XmlParser + +from ksef2.domain.models.fa3 import ( + ContactInfo, + InvoiceAddress, + InvoiceThirdParty, + MarginProcedure, +) +from ksef2.domain.models.fa3.body import ( + InvoiceRow, + InvoiceSummaryOverrides, + SaleCategory, + TaxRegime, + TransactionAddress, + TransactionIdentity, + VatRate, +) +from ksef2.infra.mappers.invoices.fa3.spec.invoice import ( + from_spec as invoice_from_spec, +) +from ksef2.infra.schema.fa3.models.schemat import Faktura +from ksef2.services.builders.fa3.root import StandardInvoiceBuilder +from tests.integration.builders.helpers import load_sample, sample_path + +VAT_SAMPLES = [ + "FA_3_Przykład_21.xml", + "FA_3_Przykład_22.xml", + "FA_3_Przykład_23.xml", + "FA_3_Przykład_24.xml", + "FA_3_Przykład_25.xml", + "FA_3_Przykład_26.xml", + "FA_3_Przykład_4.xml", + "Fa_3_Przykład_19.xml", + "Fa_3_Przykład_20.xml", +] + + +def _assert_vat_sample(sample_name: str, builder: StandardInvoiceBuilder) -> None: + parser = XmlParser() + expected_spec = load_sample(sample_path(sample_name)) + expected_invoice = invoice_from_spec(expected_spec) + actual_spec = builder.to_spec() + actual_invoice = invoice_from_spec(actual_spec) + xml_invoice = invoice_from_spec( + parser.from_bytes(builder.to_xml().encode("utf-8"), Faktura) + ) + + assert actual_spec.fa.p_2 == expected_spec.fa.p_2 + assert len(actual_spec.fa.fa_wiersz) == len(expected_spec.fa.fa_wiersz) + assert Decimal(actual_spec.fa.p_15) == Decimal(expected_spec.fa.p_15) + assert actual_invoice == expected_invoice + assert xml_invoice == expected_invoice + + +@pytest.mark.integration +def test_new_fa3_vat_builder_sample_ksef_01_matches_loaded_sample() -> None: + parser = XmlParser() + expected_spec = load_sample(sample_path("KSEF_01_VAT_STANDARD.xml")) + expected_invoice = invoice_from_spec(expected_spec) + + builder = StandardInvoiceBuilder() + _ = ( + builder.header( + generation_timestamp=datetime(2025, 12, 15, 10, 30, 0, tzinfo=timezone.utc), + system_info="KSEF_TEST_SUITE", + ) + .seller( + name="TECH-SOLUTIONS sp. z o.o.", + tax_id="9999999999", + country_code="PL", + address_line_1="ul. Marszalkowska 100", + address_line_2="00-001 Warszawa", + email="biuro@tech-solutions.pl", + phone="+48221234567", + ) + .buyer( + name="ABC HANDEL sp. z o.o.", + tax_id="1111111111", + country_code="PL", + address_line_1="ul. Poznanska 50", + address_line_2="60-001 Poznan", + customer_number="ABC-001", + email="kontakt@abc-handel.pl", + phone="+48617654321", + ) + ) + + _ = ( + builder.footer() + .add_information("TECH-SOLUTIONS sp. z o.o. - Kapital zakladowy: 500 000 PLN") + .add_registry( + krs="0000123456", + regon="146025969", + bdo="000012345", + ) + .done() + ) + + _ = ( + builder.standard() + .issue_date(date(2025, 12, 15)) + .issue_place("Warszawa") + .invoice_number("FV/2025/12/0001") + .date_of_supply(date(2025, 12, 10)) + .summary_overrides( + InvoiceSummaryOverrides( + base_rate_net_total=Decimal("4065.04"), + base_rate_vat_total=Decimal("934.96"), + total_gross=Decimal("5000.00"), + ) + ) + .rows() + .add_line_model(expected_invoice.body.rows[0]) + .done() + .payment() + .via("bank_transfer") + .due_on(date(2026, 1, 15)) + .bank_account( + "PL61109010140000071219812874", + bank_name="Santander Bank Polska S.A.", + account_description="PLN", + ) + .done() + .done() + ) + + actual_spec = builder.to_spec() + actual_invoice = invoice_from_spec(actual_spec) + xml_invoice = invoice_from_spec( + parser.from_bytes(builder.to_xml().encode("utf-8"), Faktura) + ) + + assert actual_spec.fa.p_2 == expected_spec.fa.p_2 + assert len(actual_spec.fa.fa_wiersz) == len(expected_spec.fa.fa_wiersz) + assert Decimal(actual_spec.fa.p_15) == Decimal(expected_spec.fa.p_15) + + assert actual_invoice == expected_invoice + assert xml_invoice == expected_invoice + + +@pytest.mark.integration +def test_new_fa3_vat_builder_sample_8_margin_matches_loaded_sample() -> None: + builder = StandardInvoiceBuilder() + _ = ( + builder.header( + generation_timestamp=datetime(2026, 2, 15, 9, 30, 47, tzinfo=timezone.utc), + system_info="Samplofaktur", + ) + .seller( + name="Komis ABC AGD sp. z o. o.", + tax_id="9999999999", + country_code="PL", + address_line_1="ul. Kwiatowa 1 m. 2", + address_line_2="00-001 Warszawa", + email="abc@abc.pl", + phone="667444555", + ) + .buyer( + name="F.H.U. Jan Kowalski", + tax_id="1111111111", + country_code="PL", + address_line_1="ul. Polna 1", + address_line_2="00-001 Warszawa", + customer_number="fdfd778343", + email="jan@kowalski.pl", + phone="555777999", + ) + ) + + _ = ( + builder.footer() + .add_information("Kapiał zakładowy 5 000 000") + .add_registry( + krs="0000099999", + regon="999999999", + bdo="000099999", + ) + .done() + ) + + _ = ( + builder.standard() + .issue_date(date(2026, 2, 27)) + .issue_place("Warszawa") + .invoice_number("FM2026/02/150") + .date_of_supply(date(2026, 1, 27)) + .mark_fp() + .summary_overrides( + InvoiceSummaryOverrides( + margin_total=Decimal("15000.00"), + total_gross=Decimal("15000.00"), + ) + ) + .rows() + .add_line_model( + InvoiceRow( + name="samochód używany marki Autex rocznik 2010", + quantity=Decimal("1"), + unit_of_measure="szt.", + unit_price_gross=Decimal("15000"), + gross_amount=Decimal("15000"), + net_amount=Decimal("15000"), + vat_amount=Decimal("0.00"), + tax_regime=TaxRegime.MARGIN, + gtu_code="GTU_07", + ) + ) + .done() + .annotations() + .margin_procedure(MarginProcedure.USED_GOODS) + .done() + .payment() + .via("bank_transfer") + .partial_payment_status("partial") + .add_partial_payment( + amount=Decimal("10000"), + payment_date=date(2026, 1, 27), + payment_form="cash", + ) + .due_with_description( + quantity=30, + unit="Dzień", + starting_event="Otrzymanie faktury", + ) + .bank_account( + "73111111111111111111111111", + bank_name="Bank Bankowości Bankowej S. A.", + account_description="PLN", + ) + .done() + .done() + ) + + _assert_vat_sample("FA_3_Przykład_8.xml", builder) + + +@pytest.mark.integration +def test_new_fa3_vat_builder_sample_9_exempt_mix_matches_loaded_sample() -> None: + builder = StandardInvoiceBuilder() + _ = ( + builder.header( + generation_timestamp=datetime(2026, 2, 15, 9, 30, 47, tzinfo=timezone.utc), + system_info="Samplofaktur", + ) + .seller( + name="ABC Leasing S.A.", + tax_id="9999999999", + country_code="PL", + address_line_1="ul. Kwiatowa 1 m. 2", + address_line_2="00-001 Warszawa", + email="abc@abc.pl", + phone="667444555", + ) + .buyer( + name="Gmina Bzdziszewo", + tax_id="1111111111", + country_code="PL", + address_line_1="Bzdziszewo 1", + address_line_2="00-007 Bzdziszewo", + customer_number="fdfd778343", + jst_subordinate_unit=True, + email="bzdziszewo@tuwartoinwestowac.pl", + phone="555777999", + ) + ) + + _ = builder.add_third_party_model( + InvoiceThirdParty( + tax_id="2222222222", + name="Szkoła Podstawowa w Bzdziszewie", + address=InvoiceAddress( + country_code="PL", + address_line_1="ul. Akacjowa 200", + address_line_2="00-007 Bzdziszewo", + ), + contact=ContactInfo(email="sp@bzdziszewo.p", phone="666888999"), + role="jst_recipient", + ) + ) + + _ = ( + builder.footer() + .add_information("Kapiał zakładowy 5 000 000") + .add_registry( + krs="0000099999", + regon="999999999", + bdo="000099999", + ) + .done() + ) + + _ = ( + builder.standard() + .issue_date(date(2026, 2, 15)) + .issue_place("Warszawa") + .invoice_number("FV2026/02/150") + .billing_period(period_start=date(2026, 1, 1), period_end=date(2026, 1, 1)) + .summary_overrides( + InvoiceSummaryOverrides( + base_rate_net_total=Decimal("2000.00"), + base_rate_vat_total=Decimal("460.00"), + exempt_total=Decimal("300.00"), + total_gross=Decimal("2760.00"), + ) + ) + .add_description( + key="część odsetkowa raty", + value="netto 200, vat 46", + ) + .rows() + .add_line( + name="rata leasingowa za 01/2026", + quantity=Decimal("1"), + unit_of_measure="szt.", + unit_price_net=Decimal("2000"), + vat_rate=VatRate.VAT_23, + unique_id="aaaa111133339990", + ) + .add_line( + name="pakiet ubezpieczeń za 01/2026", + quantity=Decimal("1"), + unit_of_measure="szt.", + unit_price_net=Decimal("300"), + vat_rate=VatRate.EXEMPT, + unique_id="aaaa111133339991", + ) + .done() + .annotations() + .tax_exemption(legal_basis_act="art. 43 ust. 1 pkt 37 ustawy VAT") + .done() + .payment() + .via("bank_transfer") + .due_on(date(2026, 3, 15)) + .bank_account( + "73111111111111111111111111", + bank_name="Bank Bankowości Bankowej S. A.", + account_description="PLN", + ) + .done() + .done() + ) + + _assert_vat_sample("FA_3_Przykład_9.xml", builder) + + +@pytest.mark.integration +def test_new_fa3_vat_builder_sample_ksef_05_wdt_matches_loaded_sample() -> None: + builder = StandardInvoiceBuilder() + _ = ( + builder.header( + generation_timestamp=datetime(2025, 12, 10, 8, 0, 0, tzinfo=timezone.utc), + system_info="KSEF_TEST_SUITE", + ) + .seller( + name="EXPORT-TECH sp. z o.o.", + tax_id="9999999999", + country_code="PL", + address_line_1="ul. Eksportowa 50", + address_line_2="00-500 Warszawa", + email="export@export-tech.pl", + phone="+48225556677", + ) + .buyer( + name="GERMAN ELECTRONICS GmbH", + country_code="DE", + address_line_1="Industriestrasse 100", + address_line_2="10117 Berlin", + eu_vat_id="DE999888777", + email="kontakt@german-electronics.de", + phone="+49301234567", + ) + ) + + _ = ( + builder.footer() + .add_information("EXPORT-TECH sp. z o.o. - Kapital zakladowy: 1 000 000 PLN") + .add_registry( + krs="0000777888", + regon="444555666", + bdo="000077788", + ) + .done() + ) + + _ = ( + builder.standard() + .currency("EUR") + .issue_date(date(2025, 12, 10)) + .issue_place("Warszawa") + .invoice_number("FW/2025/12/0001") + .summary_overrides( + InvoiceSummaryOverrides( + zero_rate_wdt_total=Decimal("10000.00"), + total_gross=Decimal("10000.00"), + ) + ) + .rows() + .add_line( + name="Industrial Control Systems - Model ICS-500", + quantity=Decimal("20"), + unit_of_measure="szt.", + unit_price_net=Decimal("500"), + supply_date=date(2025, 12, 8), + vat_rate=VatRate.VAT_0, + sale_category=SaleCategory.ZERO_WDT, + unique_id="KSEF05-LINE001", + ) + .done() + .payment() + .via("bank_transfer") + .due_on(date(2026, 1, 10)) + .bank_account( + "PL61109010140000071219812874", + "WBKPPLPP", + bank_name="Santander Bank Polska S.A.", + account_description="EUR", + ) + .done() + .transaction() + .delivery_terms("DAP Berlin") + .add_transport( + transport_type="road", + cargo_type="parcel", + carrier_identity=TransactionIdentity( + tax_id="6666666666", + name="EU TRANSPORT sp. z o.o.", + ), + carrier_address=TransactionAddress( + country_code="PL", + address_line_1="ul. Logistyczna 10", + address_line_2="00-500 Warszawa", + ), + shipping_from=TransactionAddress( + country_code="PL", + address_line_1="ul. Eksportowa 50", + address_line_2="00-500 Warszawa", + ), + shipping_to=TransactionAddress( + country_code="DE", + address_line_1="Industriestrasse 100", + address_line_2="10117 Berlin", + ), + ) + .done() + .done() + ) + + _assert_vat_sample("KSEF_05_WDT.xml", builder) + + +@pytest.mark.integration +def test_new_fa3_vat_builder_sample_ksef_06_export_matches_loaded_sample() -> None: + builder = StandardInvoiceBuilder() + _ = ( + builder.header( + generation_timestamp=datetime(2025, 12, 12, 12, 0, 0, tzinfo=timezone.utc), + system_info="KSEF_TEST_SUITE", + ) + .seller( + name="GLOBAL EXPORT sp. z o.o.", + tax_id="9999999999", + country_code="PL", + address_line_1="ul. Portowa 100", + address_line_2="80-001 Gdansk", + email="export@global-export.pl", + phone="+48585551234", + ) + .buyer( + name="EXPORT PARTNER sp. z o.o.", + tax_id="1111111111", + country_code="PL", + address_line_1="ul. Handlowa 25", + address_line_2="80-001 Gdansk", + email="export@partner.pl", + phone="+48585559999", + ) + ) + + _ = ( + builder.footer() + .add_information("GLOBAL EXPORT sp. z o.o. - Kapital zakladowy: 2 000 000 PLN") + .add_registry( + krs="0000888999", + regon="555666777", + bdo="000088899", + ) + .done() + ) + + _ = ( + builder.standard() + .currency("USD") + .issue_date(date(2025, 12, 12)) + .issue_place("Gdansk") + .invoice_number("FE/2025/12/0001") + .summary_overrides( + InvoiceSummaryOverrides( + zero_rate_wdt_total=Decimal("15000.00"), + total_gross=Decimal("15000.00"), + ) + ) + .rows() + .add_line( + name="Premium Industrial Equipment - Model PIE-2000", + quantity=Decimal("5"), + unit_of_measure="szt.", + unit_price_net=Decimal("3000"), + supply_date=date(2025, 12, 10), + vat_rate=VatRate.VAT_0, + sale_category=SaleCategory.ZERO_EXPORT, + unique_id="KSEF06-LINE001", + ) + .done() + .payment() + .via("bank_transfer") + .due_on(date(2026, 1, 12)) + .bank_account( + "PL61109010140000071219812874", + "WBKPPLPP", + bank_name="Santander Bank Polska S.A.", + account_description="USD", + ) + .done() + .transaction() + .delivery_terms("FOB Gdansk") + .add_transport( + transport_type="air", + cargo_type="parcel", + carrier_identity=TransactionIdentity( + tax_id="6666666666", + name="SEA CARGO sp. z o.o.", + ), + carrier_address=TransactionAddress( + country_code="PL", + address_line_1="ul. Portowa 1", + address_line_2="80-001 Gdansk", + ), + shipping_from=TransactionAddress( + country_code="PL", + address_line_1="ul. Portowa 100", + address_line_2="80-001 Gdansk", + ), + shipping_to=TransactionAddress( + country_code="US", + address_line_1="1234 Business Boulevard", + address_line_2="New York, NY 10001", + ), + ) + .done() + .done() + ) + + _assert_vat_sample("KSEF_06_EXP.xml", builder) + + +def _build_vat_from_oracle(sample_name: str) -> StandardInvoiceBuilder: + expected_invoice = invoice_from_spec(load_sample(sample_path(sample_name))) + builder = StandardInvoiceBuilder() + _ = builder.header_model(expected_invoice.header) + _ = builder.seller_model(expected_invoice.seller) + _ = builder.buyer_model(expected_invoice.buyer) + for party in expected_invoice.third_parties: + _ = builder.add_third_party_model(party) + if expected_invoice.footer is not None: + _ = builder.footer_model(expected_invoice.footer) + if expected_invoice.attachment is not None: + _ = builder.attachment_model(expected_invoice.attachment) + _ = builder.standard().from_model(expected_invoice.body).done() + return builder + + +@pytest.mark.integration +@pytest.mark.parametrize("sample_name", VAT_SAMPLES) +def test_new_fa3_vat_samples(sample_name: str) -> None: + builder = _build_vat_from_oracle(sample_name) + _assert_vat_sample(sample_name, builder) diff --git a/tests/integration/builders/helpers.py b/tests/integration/builders/helpers.py new file mode 100644 index 0000000..f5a5642 --- /dev/null +++ b/tests/integration/builders/helpers.py @@ -0,0 +1,13 @@ +from pathlib import Path +from xsdata.formats.dataclass.parsers import XmlParser +from ksef2.infra.schema.fa3.models.schemat import Faktura + + +def sample_path(name: str) -> Path: + return Path(__file__).parents[3] / "schemas" / "FA3" / "samples" / name + + +def load_sample(name: Path) -> Faktura: + parser = XmlParser() + with open(name, "rb") as f: + return parser.from_bytes(f.read(), Faktura) diff --git a/tests/unit/test_fa3_builder_metadata.py b/tests/unit/test_fa3_builder_metadata.py new file mode 100644 index 0000000..a8d1005 --- /dev/null +++ b/tests/unit/test_fa3_builder_metadata.py @@ -0,0 +1,150 @@ +from datetime import date +from decimal import Decimal +from typing import Annotated, cast, get_args, get_origin, get_type_hints + +from pydantic.fields import FieldInfo + +from ksef2.fa3 import FA3InvoiceBuilder +from ksef2.domain.models.fa3.body import VatRate +from ksef2.services.builders.fa3.body.base import BaseBodyBuilder +from ksef2.services.builders.fa3.root import StandardInvoiceBuilder +from ksef2.services.builders.fa3.sub.payment import PaymentBuilder +from ksef2.services.builders.fa3.sub.rows import RowsBuilder + + +def _field_info(annotation: object) -> FieldInfo: + assert get_origin(annotation) is Annotated + metadata = cast(tuple[object, ...], get_args(annotation)[1:]) + field_info = next(item for item in metadata if isinstance(item, FieldInfo)) + return field_info + + +def _type_hints(obj: object) -> dict[str, object]: + return cast(dict[str, object], get_type_hints(obj, include_extras=True)) + + +def test_header_metadata_is_available_via_type_hints() -> None: + hints = _type_hints(StandardInvoiceBuilder.header) + + system_info = _field_info(hints["system_info"]) + generation_timestamp = _field_info(hints["generation_timestamp"]) + + assert system_info.description == ( + "Name of the application or service that generated the invoice." + ) + assert system_info.examples == ["my-erp", "billing-service"] + assert generation_timestamp.json_schema_extra == { + "x-builder-prefer-omit-when-null": True, + "x-builder-format": "date-time", + "x-builder-priority": "advanced", + } + + +def test_body_metadata_is_available_for_billing_period() -> None: + hints = _type_hints(BaseBodyBuilder.billing_period) + + period_start = _field_info(hints["period_start"]) + period_end = _field_info(hints["period_end"]) + + assert period_start.description == ( + "Start of the billing period for period-based invoices." + ) + assert period_start.examples == ["2026-04-01"] + assert period_start.json_schema_extra == { + "x-builder-prefer-omit-when-null": True, + "x-builder-format": "date", + "x-builder-priority": "advanced", + } + assert period_end.description == ( + "End of the billing period for period-based invoices." + ) + + +def test_add_line_metadata_marks_advanced_and_override_fields() -> None: + hints = _type_hints(cast(object, RowsBuilder.add_line)) + + quantity = _field_info(hints["quantity"]) + vat_classification = _field_info(hints["vat_classification"]) + net_amount = _field_info(hints["net_amount"]) + + assert quantity.description == "Quantity billed on this line." + assert quantity.examples == ["1", "2.5"] + assert quantity.json_schema_extra == { + "x-builder-prefer-omit-when-null": True, + "x-builder-format": "decimal-string", + } + assert vat_classification.json_schema_extra == { + "x-builder-prefer-omit-when-null": True, + "x-builder-format": "object", + "x-builder-priority": "advanced", + "x-builder-schema-ref": "ksef2.domain.models.fa3.body.tax.VatClassification", + } + assert net_amount.json_schema_extra == { + "x-builder-prefer-omit-when-null": True, + "x-builder-format": "decimal-string", + "x-builder-priority": "override", + } + + +def test_payment_metadata_is_available_for_partial_payment() -> None: + hints = _type_hints(cast(object, PaymentBuilder.add_partial_payment)) + + amount = _field_info(hints["amount"]) + payment_date = _field_info(hints["payment_date"]) + + assert amount.description == "Monetary amount used for payment entries." + assert amount.examples == ["500.00"] + assert amount.json_schema_extra == { + "x-builder-prefer-omit-when-null": True, + "x-builder-format": "decimal-string", + } + assert payment_date.json_schema_extra == { + "x-builder-prefer-omit-when-null": True, + "x-builder-format": "date", + } + + +def test_runtime_builder_behavior_is_unchanged() -> None: + invoice = ( + FA3InvoiceBuilder() + .header(system_info="metadata-test") + .seller( + name="ACME S.A.", + tax_id="1234567890", + country_code="PL", + address_line_1="ul. Przykladowa 123", + ) + .buyer( + name="XYZ GmbH", + country_code="DE", + address_line_1="Unter den Linden 1", + ) + .standard() + .issue_date(date(2026, 4, 9)) + .invoice_number("FV/2026/04/0001") + .payment() + .via("bank_transfer") + .already_paid(date(2026, 4, 10)) + .add_partial_payment( + amount=Decimal("50.00"), + payment_date=date(2026, 4, 10), + ) + .done() + .rows() + .add_line( + name="Consulting service", + quantity=Decimal("1"), + unit_price_net=Decimal("100.00"), + vat_rate=VatRate.VAT_23, + ) + .done() + .done() + .build() + ) + + assert invoice.body.invoice_number == "FV/2026/04/0001" + assert invoice.body.issue_date == date(2026, 4, 9) + assert invoice.body.rows[0].vat_rate is VatRate.VAT_23 + assert invoice.body.payment is not None + assert invoice.body.payment.payment_form == "bank_transfer" + assert invoice.body.payment.partial_payments[0].amount == Decimal("50.00") diff --git a/tests/unit/test_fa3_drafts.py b/tests/unit/test_fa3_drafts.py new file mode 100644 index 0000000..72ca148 --- /dev/null +++ b/tests/unit/test_fa3_drafts.py @@ -0,0 +1,166 @@ +from datetime import date, datetime, timezone +from decimal import Decimal + +from ksef2.fa3 import FA3InvoiceBuilder, KsefInvoiceDraft, VatRate + + +def test_fa3_builder_can_roundtrip_partial_draft_json() -> None: + draft_json = ( + FA3InvoiceBuilder() + .header( + generation_timestamp=datetime(2026, 4, 8, 12, 0, tzinfo=timezone.utc), + system_info="draft json test", + ) + .seller( + name="ACME S.A.", + tax_id="1234567890", + country_code="PL", + address_line_1="ul. Przykladowa 123", + ) + .dump_state_json(indent=2) + ) + + restored_draft = KsefInvoiceDraft.model_validate_json(draft_json) + restored_builder = FA3InvoiceBuilder.from_state_json(draft_json) + + assert restored_draft.buyer is None + assert restored_draft.body is None + assert restored_draft.seller is not None + assert restored_draft.seller.tax_id == "1234567890" + + invoice = ( + restored_builder.buyer( + name="XYZ GmbH", + country_code="DE", + address_line_1="Unter den Linden 1", + ) + .standard() + .issue_date(date(2026, 4, 8)) + .invoice_number("FV/2026/04/0001") + .rows() + .add_line( + name="Consulting service", + quantity=Decimal("1"), + unit_of_measure="h", + unit_price_net=Decimal("100.00"), + vat_rate=VatRate.VAT_23, + ) + .done() + .done() + .build() + ) + + assert invoice.body.invoice_number == "FV/2026/04/0001" + assert invoice.body.rows[0].vat_rate is VatRate.VAT_23 + + +def test_fa3_builder_can_reload_existing_sections_and_continue_editing() -> None: + builder = FA3InvoiceBuilder() + _ = ( + builder.header( + generation_timestamp=datetime(2026, 4, 8, 12, 0, tzinfo=timezone.utc), + system_info="draft reload test", + ) + .seller( + name="ACME S.A.", + tax_id="1234567890", + country_code="PL", + address_line_1="ul. Przykladowa 123", + ) + .buyer( + name="XYZ GmbH", + country_code="DE", + address_line_1="Unter den Linden 1", + ) + ) + _ = builder.footer().add_information("Original footer note").done() + _ = ( + builder.standard() + .issue_date(date(2026, 4, 8)) + .invoice_number("FV/2026/04/0002") + .rows() + .add_line( + name="Initial line", + quantity=Decimal("1"), + unit_of_measure="h", + unit_price_net=Decimal("100.00"), + vat_rate=VatRate.VAT_23, + ) + .done() + .payment() + .via("bank_transfer") + .bank_account("PL10101010101010101010101010") + .done() + .done() + ) + + restored_builder = FA3InvoiceBuilder.from_state(builder.dump_state()) + _ = restored_builder.footer().add_information("Updated footer note").done() + _ = ( + restored_builder.standard() + .rows() + .add_line( + name="Follow-up line", + quantity=Decimal("2"), + unit_of_measure="h", + unit_price_net=Decimal("50.00"), + vat_rate=VatRate.VAT_23, + ) + .done() + .payment() + .due_on(date(2026, 4, 15)) + .done() + .done() + ) + + invoice = restored_builder.build() + + assert invoice.footer is not None + assert invoice.footer.additional_informations == [ + "Original footer note", + "Updated footer note", + ] + assert len(invoice.body.rows) == 2 + assert invoice.body.payment is not None + assert invoice.body.payment.payment_form == "bank_transfer" + assert invoice.body.payment.payment_terms[0].due_date == date(2026, 4, 15) + + +def test_fa3_draft_can_be_created_from_built_invoice() -> None: + invoice = ( + FA3InvoiceBuilder() + .header( + generation_timestamp=datetime(2026, 4, 8, 12, 0, tzinfo=timezone.utc), + system_info="invoice snapshot test", + ) + .seller( + name="ACME S.A.", + tax_id="1234567890", + country_code="PL", + address_line_1="ul. Przykladowa 123", + ) + .buyer( + name="XYZ GmbH", + country_code="DE", + address_line_1="Unter den Linden 1", + ) + .standard() + .issue_date(date(2026, 4, 8)) + .invoice_number("FV/2026/04/0003") + .rows() + .add_line( + name="Consulting service", + quantity=Decimal("1"), + unit_of_measure="h", + unit_price_net=Decimal("100.00"), + vat_rate=VatRate.VAT_23, + ) + .done() + .done() + .build() + ) + + draft = KsefInvoiceDraft.from_invoice(invoice) + restored_builder = FA3InvoiceBuilder.from_state(draft) + + assert restored_builder.build() == invoice diff --git a/tests/unit/test_fa3_public_api.py b/tests/unit/test_fa3_public_api.py new file mode 100644 index 0000000..3eada02 --- /dev/null +++ b/tests/unit/test_fa3_public_api.py @@ -0,0 +1,112 @@ +from datetime import date +from decimal import Decimal + +import pytest + +from ksef2.fa3 import FA3InvoiceBuilder, KsefInvoice, VatRate + + +def test_fa3_public_builder_builds_invoice() -> None: + invoice = ( + FA3InvoiceBuilder() + .header(system_info="public api test") + .seller( + name="ACME S.A.", + tax_id="1234567890", + country_code="PL", + address_line_1="ul. Przykladowa 123", + ) + .buyer( + name="XYZ GmbH", + country_code="DE", + address_line_1="Unter den Linden 1", + ) + .standard() + .issue_date(date(2026, 3, 29)) + .invoice_number("FV/2026/03/0001") + .rows() + .add_line( + name="Consulting service", + quantity=Decimal("1"), + unit_of_measure="h", + unit_price_net=Decimal("100"), + vat_rate=VatRate.VAT_23, + ) + .done() + .done() + .build() + ) + + assert isinstance(invoice, KsefInvoice) + assert invoice.body.invoice_number == "FV/2026/03/0001" + assert invoice.body.rows[0].vat_rate is VatRate.VAT_23 + + +def test_fa3_public_builder_builds_gross_priced_line() -> None: + invoice = ( + FA3InvoiceBuilder() + .header(system_info="public api gross test") + .seller( + name="ACME S.A.", + tax_id="1234567890", + country_code="PL", + address_line_1="ul. Przykladowa 123", + ) + .buyer( + name="XYZ GmbH", + country_code="DE", + address_line_1="Unter den Linden 1", + ) + .standard() + .issue_date(date(2026, 3, 29)) + .invoice_number("FV/2026/03/0002") + .rows() + .add_line( + name="Gross-priced service", + quantity=Decimal("2"), + unit_of_measure="h", + unit_price_gross=Decimal("123.00"), + vat_rate=VatRate.VAT_23, + ) + .done() + .done() + .build() + ) + + row = invoice.body.rows[0] + assert row.unit_price_net is None + assert row.unit_price_gross == Decimal("123.00") + assert row.gross_amount == Decimal("246.00") + assert row.net_amount == Decimal("200.00") + assert row.vat_amount == Decimal("46.00") + + +def test_fa3_public_builder_rejects_ambiguous_unit_price_inputs() -> None: + rows = FA3InvoiceBuilder().standard().rows() + + with pytest.raises(ValueError, match="unit_price_net or unit_price_gross"): + _ = rows.add_line( + name="Ambiguous service", + quantity=Decimal("1"), + unit_price_net=Decimal("100.00"), + unit_price_gross=Decimal("123.00"), + vat_rate=VatRate.VAT_23, + ) + + +def test_fa3_public_builder_requires_one_unit_price_input() -> None: + rows = FA3InvoiceBuilder().standard().rows() + + with pytest.raises(ValueError, match="unit_price_net or unit_price_gross"): + _ = rows.add_line( + name="Unpriced service", + quantity=Decimal("1"), + vat_rate=VatRate.VAT_23, + ) + + +def test_services_builders_exports_only_canonical_builder() -> None: + import ksef2.services.builders as builders + + assert builders.__all__ == ["FA3InvoiceBuilder"] + assert builders.FA3InvoiceBuilder is FA3InvoiceBuilder From d109cf46a5e9b5f61a4d54926574989f50cba45f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20Podsiad=C5=82y?= Date: Sun, 28 Jun 2026 22:26:41 +0200 Subject: [PATCH 07/11] feat: add public model facade --- src/ksef2/models.py | 371 +++++++++++++++++++++++++++++++++++++ tests/unit/test_imports.py | 33 +++- 2 files changed, 403 insertions(+), 1 deletion(-) create mode 100644 src/ksef2/models.py diff --git a/src/ksef2/models.py b/src/ksef2/models.py new file mode 100644 index 0000000..fa70ae9 --- /dev/null +++ b/src/ksef2/models.py @@ -0,0 +1,371 @@ +"""Public domain model facade.""" + +import warnings +from typing import TYPE_CHECKING + +from ksef2.domain.models import ( + ApiRateLimits, + AttachmentPermissionStatus, + AuthContextIdentifier, + AuthContextIdentifierType, + AuthInitResponse, + AuthOperationStatus, + AuthTokens, + AuthenticationMethod, + AuthenticationMethodCategory, + AuthenticationResumeState, + AuthenticationSession, + AuthenticationSessionsResponse, + AuthorizationGrantDetail, + AuthorizationPermissionType, + AuthorizationPermissionsQuery, + AuthorizationPermissionsQueryResponse, + AuthorizationSubjectIdentifierType, + BaseSessionResumeState, + BatchEncryptionData, + BatchFileInfo, + BatchFilePart, + BatchInvoice, + BatchInvoiceHash, + BatchPreparedPart, + BatchSessionResumeState, + BlockContextRequest, + BuyerIdentifierType, + CertificateEnrollmentData, + CertificateInfo, + CertificateLimitsResponse, + CertificateStatusValue, + CertificateSubjectIdentifierType, + CertificateTypeValue, + ChallengeResponse, + CompressionType, + CompressionTypeEnum, + ContextIdentifierType, + ContextLimits, + CreatePersonRequest, + CreateSubjectRequest, + DeletePersonRequest, + DeleteSubjectRequest, + EnableAttachmentsRequest, + EntityIdentifierType, + EntityPermission, + EntityPermissionDetail, + EntityPermissionType, + EntityPermissionsContextIdentifierType, + EntityPermissionsQuery, + EntityPermissionsQueryResponse, + EntityRole, + EntityRolesResponse, + EuEntityAdminContextIdentifierType, + EuEntityPermission, + EuEntityPermissionType, + EuEntityPermissionsQuery, + EuEntityPermissionsQueryResponse, + EuEntityQueryPermissionType, + ExportHandle, + ExportInvoicesPayload, + ExportInvoicesResponse, + ExportStatusInfo, + FormSchema, + GrantAuthorizationPermissionsRequest, + GrantEntityPermissionsRequest, + GrantEuEntityAdministrationRequest, + GrantEuEntityPermissionsRequest, + GrantIndirectPermissionsRequest, + GrantPermissionsRequest, + GrantPermissionsResponse, + GrantPersonPermissionsRequest, + GrantSubunitPermissionsRequest, + Identifier, + IdentifierType, + IndirectPermissionType, + IndirectTargetIdentifierType, + InitTokenAuthenticationRequest, + InvoiceExportStatusResponse, + InvoiceMetadata, + InvoiceMetadataAuthorizedSubject, + InvoiceMetadataBuyer, + InvoiceMetadataBuyerIdentifier, + InvoiceMetadataParams, + InvoiceMetadataSeller, + InvoiceMetadataThirdSubject, + InvoiceMetadataThirdSubjectIdentifier, + InvoicePackage, + InvoiceStatusInfo, + InvoiceType, + InvoicesFilter, + InvoicingMode, + KSeFBaseModel, + KSeFBaseParams, + ListPeppolProvidersResponse, + ListSessionsResponse, + OnlineSessionResumeState, + OpenBatchSessionRequest, + OpenBatchSessionResponse, + OpenOnlineSessionRequest, + OpenOnlineSessionResponse, + OperationStatus, + OperationStatusCode, + PackagePart, + PartUploadRequest, + PeppolProvider, + Permission, + PermissionOperationStatusResponse, + PermissionState, + PermissionType, + PersonAuthorIdentifierType, + PersonContextIdentifierType, + PersonPermissionDetail, + PersonPermissionScope, + PersonPermissionsAuthorizedIdentifierType, + PersonPermissionsContextIdentifierType, + PersonPermissionsQuery, + PersonPermissionsQueryResponse, + PersonPermissionsQueryType, + PersonPermissionsTargetIdentifierType, + PersonalPermissionDetail, + PersonalPermissionScope, + PersonalPermissionsAuthorizedIdentifierType, + PersonalPermissionsContextIdentifierType, + PersonalPermissionsQuery, + PersonalPermissionsQueryResponse, + PersonalPermissionsTargetIdentifierType, + PreparedBatch, + QueryInvoicesMetadataResponse, + QueryTokensResponse, + QueryType, + RefreshedToken, + RevokeAttachmentsRequest, + RevokePermissionsRequest, + SendInvoicePayload, + SendInvoiceResponse, + SessionInvoiceStatusResponse, + SessionInvoicesResponse, + SessionLimits, + SessionStatusResponse, + SessionSummary, + SortOrder, + SubUnit, + SubjectLimits, + SubjectType, + SubordinateEntityRoleDetail, + SubordinateEntityRoleType, + SubordinateEntityRolesQuery, + SubordinateEntityRolesQueryResponse, + SubunitIdentifierType, + SubunitPermission, + SubunitPermissionsQuery, + SubunitPermissionsQueryResponse, + ThirdSubjectIdentifierType, + TokenAuthorIdentifier, + TokenAuthorIdentifierType, + TokenContextIdentifier, + TokenContextIdentifierType, + TokenCredentials, + TokenInfo, + TokenPermission, + TokenStatus, + UnblockContextRequest, + Upo, + UpoPage, +) + +if TYPE_CHECKING: + BaseSessionState = BaseSessionResumeState + OnlineSessionState = OnlineSessionResumeState + BatchSessionState = BatchSessionResumeState + + +_DEPRECATED_EXPORTS = { + "BaseSessionState": ( + BaseSessionResumeState, + "ksef2.models.BaseSessionState is deprecated and will be removed in a " + "future release; use BaseSessionResumeState instead.", + ), + "OnlineSessionState": ( + OnlineSessionResumeState, + "ksef2.models.OnlineSessionState is deprecated and will be removed in a " + "future release; use OnlineSessionResumeState instead.", + ), + "BatchSessionState": ( + BatchSessionResumeState, + "ksef2.models.BatchSessionState is deprecated and will be removed in a " + "future release; use BatchSessionResumeState instead.", + ), +} + + +def __getattr__(name: str) -> object: + if name in _DEPRECATED_EXPORTS: + value, message = _DEPRECATED_EXPORTS[name] + warnings.warn(message, DeprecationWarning, stacklevel=2) + return value + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +__all__ = [ + "KSeFBaseModel", + "KSeFBaseParams", + "CompressionType", + "CompressionTypeEnum", + "BaseSessionResumeState", + "FormSchema", + "InvoiceStatusInfo", + "ListSessionsResponse", + "OpenOnlineSessionRequest", + "OpenOnlineSessionResponse", + "OnlineSessionResumeState", + "SessionInvoiceStatusResponse", + "SessionInvoicesResponse", + "SessionStatusResponse", + "SessionSummary", + "Upo", + "UpoPage", + "BatchFileInfo", + "BatchFilePart", + "BatchInvoice", + "BatchInvoiceHash", + "BatchPreparedPart", + "BatchEncryptionData", + "OpenBatchSessionRequest", + "BatchSessionResumeState", + "OpenBatchSessionResponse", + "PartUploadRequest", + "PreparedBatch", + "ExportHandle", + "BuyerIdentifierType", + "ExportInvoicesPayload", + "ExportInvoicesResponse", + "ExportStatusInfo", + "InvoiceExportStatusResponse", + "InvoiceMetadata", + "InvoiceMetadataAuthorizedSubject", + "InvoiceMetadataBuyer", + "InvoiceMetadataBuyerIdentifier", + "InvoiceMetadataSeller", + "InvoiceMetadataThirdSubject", + "InvoiceMetadataThirdSubjectIdentifier", + "InvoiceMetadataParams", + "InvoicePackage", + "InvoicesFilter", + "InvoiceType", + "InvoicingMode", + "PackagePart", + "QueryInvoicesMetadataResponse", + "SendInvoicePayload", + "SendInvoiceResponse", + "SortOrder", + "ThirdSubjectIdentifierType", + "AttachmentPermissionStatus", + "AuthorizationGrantDetail", + "AuthorizationPermissionsQuery", + "AuthorizationPermissionsQueryResponse", + "AuthorizationPermissionType", + "AuthorizationSubjectIdentifierType", + "CertificateSubjectIdentifierType", + "EntityPermission", + "EntityPermissionDetail", + "EntityPermissionType", + "EntityIdentifierType", + "EntityPermissionsContextIdentifierType", + "EntityPermissionsQuery", + "EntityPermissionsQueryResponse", + "EntityRole", + "EntityRolesResponse", + "EuEntityAdminContextIdentifierType", + "EuEntityPermission", + "EuEntityPermissionsQuery", + "EuEntityPermissionsQueryResponse", + "EuEntityPermissionType", + "EuEntityQueryPermissionType", + "GrantAuthorizationPermissionsRequest", + "GrantEntityPermissionsRequest", + "GrantEuEntityAdministrationRequest", + "GrantEuEntityPermissionsRequest", + "GrantIndirectPermissionsRequest", + "GrantPermissionsResponse", + "GrantPersonPermissionsRequest", + "GrantSubunitPermissionsRequest", + "IndirectPermissionType", + "IndirectTargetIdentifierType", + "OperationStatus", + "OperationStatusCode", + "PermissionOperationStatusResponse", + "PermissionState", + "PersonalPermissionDetail", + "PersonalPermissionsAuthorizedIdentifierType", + "PersonalPermissionsContextIdentifierType", + "PersonalPermissionsQuery", + "PersonalPermissionsQueryResponse", + "PersonalPermissionsTargetIdentifierType", + "PersonalPermissionScope", + "PersonPermissionDetail", + "PersonAuthorIdentifierType", + "PersonContextIdentifierType", + "PersonPermissionsAuthorizedIdentifierType", + "PersonPermissionsContextIdentifierType", + "PersonPermissionsQuery", + "PersonPermissionsQueryResponse", + "PersonPermissionsTargetIdentifierType", + "PersonPermissionsQueryType", + "PersonPermissionScope", + "QueryType", + "SubordinateEntityRoleDetail", + "SubordinateEntityRolesQuery", + "SubordinateEntityRolesQueryResponse", + "SubordinateEntityRoleType", + "SubunitIdentifierType", + "SubunitPermission", + "SubunitPermissionsQuery", + "SubunitPermissionsQueryResponse", + "AuthContextIdentifier", + "AuthContextIdentifierType", + "BlockContextRequest", + "CreatePersonRequest", + "CreateSubjectRequest", + "DeletePersonRequest", + "DeleteSubjectRequest", + "EnableAttachmentsRequest", + "Identifier", + "IdentifierType", + "Permission", + "PermissionType", + "GrantPermissionsRequest", + "RevokeAttachmentsRequest", + "RevokePermissionsRequest", + "SubjectType", + "SubUnit", + "UnblockContextRequest", + "QueryTokensResponse", + "TokenAuthorIdentifier", + "TokenAuthorIdentifierType", + "TokenContextIdentifier", + "TokenContextIdentifierType", + "TokenInfo", + "TokenPermission", + "TokenStatus", + "AuthenticationMethodCategory", + "AuthenticationMethod", + "AuthenticationResumeState", + "AuthenticationSession", + "AuthenticationSessionsResponse", + "AuthInitResponse", + "AuthOperationStatus", + "AuthTokens", + "ChallengeResponse", + "ContextIdentifierType", + "InitTokenAuthenticationRequest", + "RefreshedToken", + "TokenCredentials", + "CertificateEnrollmentData", + "CertificateInfo", + "CertificateLimitsResponse", + "CertificateStatusValue", + "CertificateTypeValue", + "ApiRateLimits", + "ContextLimits", + "SessionLimits", + "SubjectLimits", + "PeppolProvider", + "ListPeppolProvidersResponse", +] diff --git a/tests/unit/test_imports.py b/tests/unit/test_imports.py index 846dfcd..8ff0ed2 100644 --- a/tests/unit/test_imports.py +++ b/tests/unit/test_imports.py @@ -31,10 +31,12 @@ def blocked_import(name, globals=None, locals=None, fromlist=(), level=0): def test_public_clients_import() -> None: - from ksef2 import AsyncClient, Client + from ksef2 import AsyncClient, CertificateStore, CertificateStoreProtocol, Client assert Client.__name__ == "Client" assert AsyncClient.__name__ == "AsyncClient" + assert CertificateStore.__name__ == "CertificateStore" + assert CertificateStoreProtocol.__name__ == "CertificateStoreProtocol" def test_root_error_surface_import() -> None: @@ -97,6 +99,28 @@ def test_common_domain_models_import() -> None: assert InvoiceMetadataParams.__name__ == "InvoiceMetadataParams" +def test_public_models_import() -> None: + from ksef2.models import ( + ContextLimits, + InvoiceMetadataParams, + InvoicesFilter, + SessionLimits, + ) + + assert InvoicesFilter.__name__ == "InvoicesFilter" + assert InvoiceMetadataParams.__name__ == "InvoiceMetadataParams" + assert ContextLimits.__name__ == "ContextLimits" + assert SessionLimits.__name__ == "SessionLimits" + + +def test_public_fa3_import() -> None: + from ksef2.fa3 import FA3InvoiceBuilder, KsefInvoice, KsefInvoiceDraft + + assert FA3InvoiceBuilder.__name__ == "FA3InvoiceBuilder" + assert KsefInvoice.__name__ == "KsefInvoice" + assert KsefInvoiceDraft.__name__ == "KsefInvoiceDraft" + + def test_public_profiles_import() -> None: from ksef2.profiles import Profile, ProfileStore, TokenProfileAuth @@ -113,6 +137,13 @@ def test_public_xades_import() -> None: assert sign_xades.__name__ == "sign_xades" +def test_public_renderers_import() -> None: + from ksef2.renderers import InvoicePDFExporter, InvoiceXSLTRenderer + + assert InvoicePDFExporter.__name__ == "InvoicePDFExporter" + assert InvoiceXSLTRenderer.__name__ == "InvoiceXSLTRenderer" + + def test_middlewares_import() -> None: from ksef2.core import middlewares From 20d8914324981bf387b04c3e58facf5032ed93f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20Podsiad=C5=82y?= Date: Sun, 28 Jun 2026 22:27:04 +0200 Subject: [PATCH 08/11] refactor: remove postponed annotations --- src/ksef2/infra/mappers/permissions/requests/shared.py | 3 --- src/ksef2/infra/mappers/permissions/responses/__init__.py | 2 -- src/ksef2/infra/mappers/permissions/responses/grants.py | 2 -- tests/integration/conftest.py | 2 -- tests/integration/test_active_sessions.py | 3 --- tests/integration/test_cli_export_invoices.py | 2 -- tests/integration/test_limits.py | 2 -- tests/integration/test_permissions.py | 4 +--- tests/integration/test_testdata.py | 2 -- tests/integration/test_token_auth.py | 2 -- tests/integration/test_token_lifecycle.py | 2 -- tests/integration/test_token_refresh.py | 2 -- tests/integration/test_xades_auth.py | 2 -- 13 files changed, 1 insertion(+), 29 deletions(-) diff --git a/src/ksef2/infra/mappers/permissions/requests/shared.py b/src/ksef2/infra/mappers/permissions/requests/shared.py index 529b1e6..0793399 100644 --- a/src/ksef2/infra/mappers/permissions/requests/shared.py +++ b/src/ksef2/infra/mappers/permissions/requests/shared.py @@ -1,6 +1,3 @@ -from __future__ import annotations - - from ksef2.infra.schema.api import spec diff --git a/src/ksef2/infra/mappers/permissions/responses/__init__.py b/src/ksef2/infra/mappers/permissions/responses/__init__.py index d567ef4..9e95794 100644 --- a/src/ksef2/infra/mappers/permissions/responses/__init__.py +++ b/src/ksef2/infra/mappers/permissions/responses/__init__.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from ksef2.infra.mappers.permissions.responses.grants import ( from_spec as grant_from_spec, ) diff --git a/src/ksef2/infra/mappers/permissions/responses/grants.py b/src/ksef2/infra/mappers/permissions/responses/grants.py index c16a4d4..a4dee82 100644 --- a/src/ksef2/infra/mappers/permissions/responses/grants.py +++ b/src/ksef2/infra/mappers/permissions/responses/grants.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from enum import Enum from functools import singledispatch from typing import cast, overload diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 02ac6b6..8da7886 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import os import sys from dataclasses import dataclass diff --git a/tests/integration/test_active_sessions.py b/tests/integration/test_active_sessions.py index c18f549..b9c863f 100644 --- a/tests/integration/test_active_sessions.py +++ b/tests/integration/test_active_sessions.py @@ -1,6 +1,3 @@ -from __future__ import annotations - - import pytest from ksef2 import Client diff --git a/tests/integration/test_cli_export_invoices.py b/tests/integration/test_cli_export_invoices.py index 37ca83e..b3ac9fa 100644 --- a/tests/integration/test_cli_export_invoices.py +++ b/tests/integration/test_cli_export_invoices.py @@ -7,8 +7,6 @@ uv run pytest tests/integration/test_cli_export_invoices.py -v -m integration """ -from __future__ import annotations - import time from pathlib import Path diff --git a/tests/integration/test_limits.py b/tests/integration/test_limits.py index 7965e80..73cf553 100644 --- a/tests/integration/test_limits.py +++ b/tests/integration/test_limits.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import pytest from ksef2.domain.models.limits import ApiRateLimits, ContextLimits, SubjectLimits diff --git a/tests/integration/test_permissions.py b/tests/integration/test_permissions.py index a71e470..d27a6b0 100644 --- a/tests/integration/test_permissions.py +++ b/tests/integration/test_permissions.py @@ -8,8 +8,6 @@ source .env.test && uv run pytest tests/integration/test_permissions.py -v -m integration """ -from __future__ import annotations - import time from typing import TYPE_CHECKING, Generator, TypedDict @@ -84,7 +82,7 @@ def _wait_for_permission_operation( @pytest.fixture(scope="module") def permissions_context( real_client: Client, - ksef_credentials: KSeFCredentials, + ksef_credentials: "KSeFCredentials", ) -> Generator[PermissionContext, None, None]: """Create an authenticated session using existing credentials. diff --git a/tests/integration/test_testdata.py b/tests/integration/test_testdata.py index 31c9b15..a1940f0 100644 --- a/tests/integration/test_testdata.py +++ b/tests/integration/test_testdata.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import pytest from ksef2.domain.models.testdata import ( diff --git a/tests/integration/test_token_auth.py b/tests/integration/test_token_auth.py index 9d4aad4..e4f4e50 100644 --- a/tests/integration/test_token_auth.py +++ b/tests/integration/test_token_auth.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import pytest from ksef2 import Client diff --git a/tests/integration/test_token_lifecycle.py b/tests/integration/test_token_lifecycle.py index 1b78c0a..4abc1e2 100644 --- a/tests/integration/test_token_lifecycle.py +++ b/tests/integration/test_token_lifecycle.py @@ -4,8 +4,6 @@ uv run pytest tests/integration/test_token_lifecycle.py -v -m integration """ -from __future__ import annotations - import pytest from ksef2 import Client, Environment diff --git a/tests/integration/test_token_refresh.py b/tests/integration/test_token_refresh.py index ff23e66..540fdd7 100644 --- a/tests/integration/test_token_refresh.py +++ b/tests/integration/test_token_refresh.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from datetime import datetime, timezone import pytest diff --git a/tests/integration/test_xades_auth.py b/tests/integration/test_xades_auth.py index 2515784..4981c53 100644 --- a/tests/integration/test_xades_auth.py +++ b/tests/integration/test_xades_auth.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import pytest from ksef2.clients.authenticated import AuthenticatedClient From 4c18a03ae59c4a5f847f39a18493caaf41928c42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20Podsiad=C5=82y?= Date: Sun, 28 Jun 2026 22:27:21 +0200 Subject: [PATCH 09/11] docs: restructure SDK documentation --- README.md | 9 +- README.pl.md | 11 +- docs/README.md | 26 + docs/assets/ksef2-cli-dark-logo.png | Bin 0 -> 72161 bytes docs/assets/ksef2-cli-light-logo.png | Bin 0 -> 77645 bytes docs/assets/ksef2-mcp-dark-logo.png | Bin 0 -> 86247 bytes docs/assets/ksef2-mcp-light-logo.png | Bin 0 -> 88850 bytes docs/assets/logo-dark.png | Bin 59042 -> 59889 bytes docs/assets/logo-light.png | Bin 61768 -> 62650 bytes docs/docs.manifest.json | 462 ++++++++++++++++- docs/en/concepts/authentication-methods.mdx | 221 ++++++++ docs/en/concepts/certificates.mdx | 116 +++++ docs/en/concepts/clients-and-lifecycle.mdx | 145 ++++++ docs/en/concepts/encryption.mdx | 96 ++++ docs/en/concepts/environments.mdx | 94 ++++ docs/en/concepts/glossary.mdx | 127 +++++ docs/en/concepts/invoice-lifecycle.mdx | 157 ++++++ docs/en/concepts/limits.mdx | 94 ++++ docs/en/concepts/overview.mdx | 142 +++++ docs/en/concepts/peppol.mdx | 68 +++ docs/en/concepts/permissions.mdx | 108 ++++ docs/en/concepts/querying-and-exports.mdx | 155 ++++++ docs/en/concepts/sessions.mdx | 167 ++++++ docs/en/concepts/status-and-upo.mdx | 168 ++++++ docs/en/concepts/xades.mdx | 94 ++++ docs/en/contributing/sync-generation.md | 4 +- docs/en/getting-started/overview.mdx | 102 ++++ docs/en/getting-started/quickstart.md | 119 ----- docs/en/getting-started/quickstart.mdx | 214 ++++++++ docs/en/guides/admin.md | 88 ---- docs/en/guides/client.md | 110 ---- docs/en/guides/errors.md | 142 ----- docs/en/guides/fa3-builder.md | 109 ++++ docs/en/guides/invoices.md | 119 ----- .../authenticate.mdx} | 56 +- docs/en/how-to-guides/build-fa3-invoices.mdx | 484 ++++++++++++++++++ docs/en/how-to-guides/client-setup.mdx | 191 +++++++ .../configure-certificate-store.mdx | 149 ++++++ .../how-to-guides/configure-permissions.mdx | 217 ++++++++ docs/en/how-to-guides/download-invoices.mdx | 199 +++++++ docs/en/how-to-guides/get-status-and-upo.mdx | 257 ++++++++++ .../inspect-encryption-certificates.mdx | 121 +++++ docs/en/how-to-guides/manage-certificates.mdx | 153 ++++++ docs/en/how-to-guides/manage-limits.mdx | 149 ++++++ docs/en/how-to-guides/manage-tokens.mdx | 139 +++++ docs/en/how-to-guides/migrate-to-1-0-0.mdx | 185 +++++++ .../{workflows => how-to-guides}/overview.mdx | 111 ++-- docs/en/how-to-guides/profiles.mdx | 276 ++++++++++ docs/en/how-to-guides/query-invoices.mdx | 207 ++++++++ .../how-to-guides/query-peppol-providers.mdx | 85 +++ docs/en/how-to-guides/send-invoices.mdx | 273 ++++++++++ docs/en/how-to-guides/use-test-data.mdx | 149 ++++++ docs/en/how-to-guides/use-xades-helpers.mdx | 147 ++++++ docs/en/intro.md | 41 -- docs/en/raw/authentication.md | 106 ---- docs/en/raw/endpoint-map.md | 56 -- docs/en/raw/overview.md | 77 --- docs/en/raw/sessions-invoices.md | 109 ---- docs/en/reference/client-lifecycle.mdx | 140 +++++ docs/en/reference/errors.mdx | 119 +++++ .../en/reference/low-level/authentication.mdx | 94 ++++ docs/en/reference/low-level/endpoint-map.mdx | 56 ++ docs/en/reference/low-level/overview.mdx | 81 +++ .../reference/low-level/sessions-invoices.mdx | 120 +++++ docs/en/reference/operations.mdx | 208 ++++++++ .../public-api.mdx} | 32 +- docs/en/reference/release-notes-1-0-0.mdx | 118 +++++ docs/en/workflows/building-invoices.mdx | 114 +++++ docs/en/workflows/certificates.mdx | 94 ---- docs/en/workflows/client-setup.mdx | 21 + docs/en/workflows/downloading-invoices.mdx | 97 ---- docs/en/workflows/encryption-certificates.mdx | 22 + docs/en/workflows/limits.mdx | 88 ---- docs/en/workflows/peppol.mdx | 49 -- docs/en/workflows/permissions.mdx | 121 ----- docs/en/workflows/querying-invoices.mdx | 108 ---- docs/en/workflows/sending-invoices.mdx | 158 ------ docs/en/workflows/status-upo.mdx | 96 ---- docs/en/workflows/test-data.mdx | 81 --- docs/en/workflows/tokens.mdx | 77 --- docs/en/workflows/xades.mdx | 104 ---- docs/guides/fa3-builder.md | 222 ++++++++ docs/pl/concepts/authentication-methods.mdx | 221 ++++++++ docs/pl/concepts/certificates.mdx | 116 +++++ docs/pl/concepts/clients-and-lifecycle.mdx | 147 ++++++ docs/pl/concepts/encryption.mdx | 97 ++++ docs/pl/concepts/environments.mdx | 96 ++++ docs/pl/concepts/glossary.mdx | 129 +++++ docs/pl/concepts/invoice-lifecycle.mdx | 157 ++++++ docs/pl/concepts/limits.mdx | 97 ++++ docs/pl/concepts/overview.mdx | 142 +++++ docs/pl/concepts/peppol.mdx | 68 +++ docs/pl/concepts/permissions.mdx | 108 ++++ docs/pl/concepts/querying-and-exports.mdx | 156 ++++++ docs/pl/concepts/sessions.mdx | 168 ++++++ docs/pl/concepts/status-and-upo.mdx | 169 ++++++ docs/pl/concepts/xades.mdx | 93 ++++ docs/pl/contributing/sync-generation.md | 4 +- docs/pl/getting-started/overview.mdx | 107 ++++ docs/pl/getting-started/quickstart.md | 100 ---- docs/pl/getting-started/quickstart.mdx | 217 ++++++++ docs/pl/guides/admin.md | 69 --- docs/pl/guides/client.md | 98 ---- docs/pl/guides/errors.md | 143 ------ docs/pl/guides/fa3-builder.md | 66 +++ docs/pl/guides/invoices.md | 62 --- .../authenticate.mdx} | 56 +- docs/pl/how-to-guides/build-fa3-invoices.mdx | 482 +++++++++++++++++ docs/pl/how-to-guides/client-setup.mdx | 194 +++++++ .../configure-certificate-store.mdx | 148 ++++++ .../how-to-guides/configure-permissions.mdx | 215 ++++++++ docs/pl/how-to-guides/download-invoices.mdx | 201 ++++++++ docs/pl/how-to-guides/get-status-and-upo.mdx | 257 ++++++++++ .../inspect-encryption-certificates.mdx | 123 +++++ docs/pl/how-to-guides/manage-certificates.mdx | 154 ++++++ docs/pl/how-to-guides/manage-limits.mdx | 150 ++++++ docs/pl/how-to-guides/manage-tokens.mdx | 140 +++++ docs/pl/how-to-guides/migrate-to-1-0-0.mdx | 191 +++++++ .../{workflows => how-to-guides}/overview.mdx | 109 ++-- docs/pl/how-to-guides/profiles.mdx | 276 ++++++++++ docs/pl/how-to-guides/query-invoices.mdx | 207 ++++++++ .../how-to-guides/query-peppol-providers.mdx | 86 ++++ docs/pl/how-to-guides/send-invoices.mdx | 270 ++++++++++ docs/pl/how-to-guides/use-test-data.mdx | 151 ++++++ docs/pl/how-to-guides/use-xades-helpers.mdx | 148 ++++++ docs/pl/intro.md | 41 -- docs/pl/raw/authentication.md | 107 ---- docs/pl/raw/endpoint-map.md | 56 -- docs/pl/raw/overview.md | 75 --- docs/pl/raw/sessions-invoices.md | 109 ---- docs/pl/reference/client-lifecycle.mdx | 141 +++++ docs/pl/reference/errors.mdx | 119 +++++ .../pl/reference/low-level/authentication.mdx | 96 ++++ docs/pl/reference/low-level/endpoint-map.mdx | 56 ++ docs/pl/reference/low-level/overview.mdx | 81 +++ .../reference/low-level/sessions-invoices.mdx | 119 +++++ docs/pl/reference/operations.mdx | 208 ++++++++ .../public-api.mdx} | 32 +- docs/pl/reference/release-notes-1-0-0.mdx | 121 +++++ docs/pl/workflows/building-invoices.mdx | 114 +++++ docs/pl/workflows/certificates.mdx | 94 ---- docs/pl/workflows/client-setup.mdx | 23 + docs/pl/workflows/downloading-invoices.mdx | 96 ---- docs/pl/workflows/encryption-certificates.mdx | 22 + docs/pl/workflows/limits.mdx | 91 ---- docs/pl/workflows/peppol.mdx | 49 -- docs/pl/workflows/permissions.mdx | 121 ----- docs/pl/workflows/querying-invoices.mdx | 109 ---- docs/pl/workflows/sending-invoices.mdx | 158 ------ docs/pl/workflows/status-upo.mdx | 95 ---- docs/pl/workflows/test-data.mdx | 84 --- docs/pl/workflows/tokens.mdx | 78 --- docs/pl/workflows/xades.mdx | 104 ---- 153 files changed, 14707 insertions(+), 4104 deletions(-) create mode 100644 docs/README.md create mode 100644 docs/assets/ksef2-cli-dark-logo.png create mode 100644 docs/assets/ksef2-cli-light-logo.png create mode 100644 docs/assets/ksef2-mcp-dark-logo.png create mode 100644 docs/assets/ksef2-mcp-light-logo.png create mode 100644 docs/en/concepts/authentication-methods.mdx create mode 100644 docs/en/concepts/certificates.mdx create mode 100644 docs/en/concepts/clients-and-lifecycle.mdx create mode 100644 docs/en/concepts/encryption.mdx create mode 100644 docs/en/concepts/environments.mdx create mode 100644 docs/en/concepts/glossary.mdx create mode 100644 docs/en/concepts/invoice-lifecycle.mdx create mode 100644 docs/en/concepts/limits.mdx create mode 100644 docs/en/concepts/overview.mdx create mode 100644 docs/en/concepts/peppol.mdx create mode 100644 docs/en/concepts/permissions.mdx create mode 100644 docs/en/concepts/querying-and-exports.mdx create mode 100644 docs/en/concepts/sessions.mdx create mode 100644 docs/en/concepts/status-and-upo.mdx create mode 100644 docs/en/concepts/xades.mdx create mode 100644 docs/en/getting-started/overview.mdx delete mode 100644 docs/en/getting-started/quickstart.md create mode 100644 docs/en/getting-started/quickstart.mdx delete mode 100644 docs/en/guides/admin.md delete mode 100644 docs/en/guides/client.md delete mode 100644 docs/en/guides/errors.md create mode 100644 docs/en/guides/fa3-builder.md delete mode 100644 docs/en/guides/invoices.md rename docs/en/{workflows/authentication.mdx => how-to-guides/authenticate.mdx} (80%) create mode 100644 docs/en/how-to-guides/build-fa3-invoices.mdx create mode 100644 docs/en/how-to-guides/client-setup.mdx create mode 100644 docs/en/how-to-guides/configure-certificate-store.mdx create mode 100644 docs/en/how-to-guides/configure-permissions.mdx create mode 100644 docs/en/how-to-guides/download-invoices.mdx create mode 100644 docs/en/how-to-guides/get-status-and-upo.mdx create mode 100644 docs/en/how-to-guides/inspect-encryption-certificates.mdx create mode 100644 docs/en/how-to-guides/manage-certificates.mdx create mode 100644 docs/en/how-to-guides/manage-limits.mdx create mode 100644 docs/en/how-to-guides/manage-tokens.mdx create mode 100644 docs/en/how-to-guides/migrate-to-1-0-0.mdx rename docs/en/{workflows => how-to-guides}/overview.mdx (64%) create mode 100644 docs/en/how-to-guides/profiles.mdx create mode 100644 docs/en/how-to-guides/query-invoices.mdx create mode 100644 docs/en/how-to-guides/query-peppol-providers.mdx create mode 100644 docs/en/how-to-guides/send-invoices.mdx create mode 100644 docs/en/how-to-guides/use-test-data.mdx create mode 100644 docs/en/how-to-guides/use-xades-helpers.mdx delete mode 100644 docs/en/intro.md delete mode 100644 docs/en/raw/authentication.md delete mode 100644 docs/en/raw/endpoint-map.md delete mode 100644 docs/en/raw/overview.md delete mode 100644 docs/en/raw/sessions-invoices.md create mode 100644 docs/en/reference/client-lifecycle.mdx create mode 100644 docs/en/reference/errors.mdx create mode 100644 docs/en/reference/low-level/authentication.mdx create mode 100644 docs/en/reference/low-level/endpoint-map.mdx create mode 100644 docs/en/reference/low-level/overview.mdx create mode 100644 docs/en/reference/low-level/sessions-invoices.mdx create mode 100644 docs/en/reference/operations.mdx rename docs/en/{guides/public-api.md => reference/public-api.mdx} (70%) create mode 100644 docs/en/reference/release-notes-1-0-0.mdx create mode 100644 docs/en/workflows/building-invoices.mdx delete mode 100644 docs/en/workflows/certificates.mdx delete mode 100644 docs/en/workflows/downloading-invoices.mdx delete mode 100644 docs/en/workflows/limits.mdx delete mode 100644 docs/en/workflows/peppol.mdx delete mode 100644 docs/en/workflows/permissions.mdx delete mode 100644 docs/en/workflows/querying-invoices.mdx delete mode 100644 docs/en/workflows/sending-invoices.mdx delete mode 100644 docs/en/workflows/status-upo.mdx delete mode 100644 docs/en/workflows/test-data.mdx delete mode 100644 docs/en/workflows/tokens.mdx delete mode 100644 docs/en/workflows/xades.mdx create mode 100644 docs/guides/fa3-builder.md create mode 100644 docs/pl/concepts/authentication-methods.mdx create mode 100644 docs/pl/concepts/certificates.mdx create mode 100644 docs/pl/concepts/clients-and-lifecycle.mdx create mode 100644 docs/pl/concepts/encryption.mdx create mode 100644 docs/pl/concepts/environments.mdx create mode 100644 docs/pl/concepts/glossary.mdx create mode 100644 docs/pl/concepts/invoice-lifecycle.mdx create mode 100644 docs/pl/concepts/limits.mdx create mode 100644 docs/pl/concepts/overview.mdx create mode 100644 docs/pl/concepts/peppol.mdx create mode 100644 docs/pl/concepts/permissions.mdx create mode 100644 docs/pl/concepts/querying-and-exports.mdx create mode 100644 docs/pl/concepts/sessions.mdx create mode 100644 docs/pl/concepts/status-and-upo.mdx create mode 100644 docs/pl/concepts/xades.mdx create mode 100644 docs/pl/getting-started/overview.mdx delete mode 100644 docs/pl/getting-started/quickstart.md create mode 100644 docs/pl/getting-started/quickstart.mdx delete mode 100644 docs/pl/guides/admin.md delete mode 100644 docs/pl/guides/client.md delete mode 100644 docs/pl/guides/errors.md create mode 100644 docs/pl/guides/fa3-builder.md delete mode 100644 docs/pl/guides/invoices.md rename docs/pl/{workflows/authentication.mdx => how-to-guides/authenticate.mdx} (79%) create mode 100644 docs/pl/how-to-guides/build-fa3-invoices.mdx create mode 100644 docs/pl/how-to-guides/client-setup.mdx create mode 100644 docs/pl/how-to-guides/configure-certificate-store.mdx create mode 100644 docs/pl/how-to-guides/configure-permissions.mdx create mode 100644 docs/pl/how-to-guides/download-invoices.mdx create mode 100644 docs/pl/how-to-guides/get-status-and-upo.mdx create mode 100644 docs/pl/how-to-guides/inspect-encryption-certificates.mdx create mode 100644 docs/pl/how-to-guides/manage-certificates.mdx create mode 100644 docs/pl/how-to-guides/manage-limits.mdx create mode 100644 docs/pl/how-to-guides/manage-tokens.mdx create mode 100644 docs/pl/how-to-guides/migrate-to-1-0-0.mdx rename docs/pl/{workflows => how-to-guides}/overview.mdx (64%) create mode 100644 docs/pl/how-to-guides/profiles.mdx create mode 100644 docs/pl/how-to-guides/query-invoices.mdx create mode 100644 docs/pl/how-to-guides/query-peppol-providers.mdx create mode 100644 docs/pl/how-to-guides/send-invoices.mdx create mode 100644 docs/pl/how-to-guides/use-test-data.mdx create mode 100644 docs/pl/how-to-guides/use-xades-helpers.mdx delete mode 100644 docs/pl/intro.md delete mode 100644 docs/pl/raw/authentication.md delete mode 100644 docs/pl/raw/endpoint-map.md delete mode 100644 docs/pl/raw/overview.md delete mode 100644 docs/pl/raw/sessions-invoices.md create mode 100644 docs/pl/reference/client-lifecycle.mdx create mode 100644 docs/pl/reference/errors.mdx create mode 100644 docs/pl/reference/low-level/authentication.mdx create mode 100644 docs/pl/reference/low-level/endpoint-map.mdx create mode 100644 docs/pl/reference/low-level/overview.mdx create mode 100644 docs/pl/reference/low-level/sessions-invoices.mdx create mode 100644 docs/pl/reference/operations.mdx rename docs/pl/{guides/public-api.md => reference/public-api.mdx} (70%) create mode 100644 docs/pl/reference/release-notes-1-0-0.mdx create mode 100644 docs/pl/workflows/building-invoices.mdx delete mode 100644 docs/pl/workflows/certificates.mdx delete mode 100644 docs/pl/workflows/downloading-invoices.mdx delete mode 100644 docs/pl/workflows/limits.mdx delete mode 100644 docs/pl/workflows/peppol.mdx delete mode 100644 docs/pl/workflows/permissions.mdx delete mode 100644 docs/pl/workflows/querying-invoices.mdx delete mode 100644 docs/pl/workflows/sending-invoices.mdx delete mode 100644 docs/pl/workflows/status-upo.mdx delete mode 100644 docs/pl/workflows/test-data.mdx delete mode 100644 docs/pl/workflows/tokens.mdx delete mode 100644 docs/pl/workflows/xades.mdx diff --git a/README.md b/README.md index 2c82c72..58d546c 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@

- + ksef2 @@ -219,10 +219,11 @@ invoice downloads after KSeF assigns invoice numbers. ## Documentation -- Online docs: +- Online docs: - Quickstart: -- Workflow guides: -- API reference: +- How-to guides: +- Public API contract: +- Python API reference: - Source docs: [`docs/en`](https://github.com/stacking-hq/ksef2/tree/main/docs/en) and [`docs/pl`](https://github.com/stacking-hq/ksef2/tree/main/docs/pl) - Runnable examples: [`scripts/examples`](https://github.com/stacking-hq/ksef2/tree/main/scripts/examples) diff --git a/README.pl.md b/README.pl.md index ffa7d24..b3c31e4 100644 --- a/README.pl.md +++ b/README.pl.md @@ -14,7 +14,7 @@

-
+ ksef2 @@ -90,7 +90,7 @@ Wybierz metodę uwierzytelnienia pasującą do środowiska, z którym pracujesz. ```python from ksef2 import Client, Environment -from ksef2.core.xades import ( +from ksef2.xades import ( load_certificate_from_pem, load_private_key_from_pem, ) @@ -222,10 +222,11 @@ pobierania paczek i bezpośredniego pobierania faktur. ## Dokumentacja -- Dokumentacja online: +- Dokumentacja online: - Quickstart: -- Przepływy: -- API reference (EN): +- Przewodniki praktyczne: +- Kontrakt publicznego API: +- Referencja API Pythona (EN): - Źródła dokumentacji: [`docs/en`](https://github.com/stacking-hq/ksef2/tree/main/docs/en) i [`docs/pl`](https://github.com/stacking-hq/ksef2/tree/main/docs/pl) - Uruchamialne przykłady: [`scripts/examples`](https://github.com/stacking-hq/ksef2/tree/main/scripts/examples) diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..c60e8a8 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,26 @@ +# SDK documentation source layout + +This `docs/` tree is product-owned source prose. The published Starlight site +copies it into `ksef2-docs/src/content/docs/sdk/` during docs sync. + +Keep the source tree aligned with the published sidebar groups: + +- `getting-started/`: orientation and quickstart pages. +- `concepts/`: explanations of KSeF domain objects and workflow mechanics. +- `how-to-guides/`: task pages with action-oriented filenames. +- `reference/`: stable contracts, exact behavior, and low-level API notes. +- `contributing/`: maintainer-facing docs. + +`docs.manifest.json` is the index for authored SDK documentation. Add each +published page there under the right category, using a locale-relative path such +as `how-to-guides/send-invoices.mdx`. The `en/` and `pl/` trees must stay +mirrored for every path listed in the manifest. + +For manifest-backed docs, the published site copies only Markdown pages indexed +by `docs.manifest.json`. Keep draft, legacy, or migration notes outside the +manifest when they should not become public routes, or delete them once they no +longer have a source role. + +Do not commit generated API reference pages here. The Python API reference is +generated by `ksef2-docs` from SDK source code during sync and appended to the +Starlight sidebar by the docs app. diff --git a/docs/assets/ksef2-cli-dark-logo.png b/docs/assets/ksef2-cli-dark-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..ffe0d674949f70471ff3a56eeab209d41ac478f2 GIT binary patch literal 72161 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 literal 0 HcmV?d00001 diff --git a/docs/assets/ksef2-cli-light-logo.png b/docs/assets/ksef2-cli-light-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..2111ea2e59aaa66085291ee271e519f98155cddd GIT binary patch literal 77645 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 literal 0 HcmV?d00001 diff --git a/docs/assets/ksef2-mcp-dark-logo.png b/docs/assets/ksef2-mcp-dark-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..0415cb91386bb15fa71fceb1a22eff31d3764dec GIT binary patch literal 86247 zcmZU51z1$u_x9Y&HEBT_Q4j%XNu`ty0qO3JA*D-Hq(NF*8l+rtd^ zcc1?^_qj68IkV5&E8g|4wHNxnlM+F{PjnvwfuM_t3d%wts3s7|-{0@v2A@<-4AMd% ze|eb*2)q*$5O{25V`*q&ZUBK$hq#5*Ni@DDY1UFJA%FM=)sIs@Q_5b3CaC1TP$rhC zmm=v~hKpO0M4`C&BEL$er-Pe+eDW{K!^r#i_!y;$?{yrmMrzX*r57IaBa)+;$(q(l zu6kG|)6lvf$`3=r6@C7;MMbY^T4$z!H%(Zf-?Dmrb?+z4tv%wcof2}2JgVRmsSD*G zoW}8)d&@yLi#|J=o@P5mg;5lmij{~XP4xcFtVmubL!C9ltM#R59g467DhwRdW{gO# zZxLt_j$b&FGl>??iWq3)$~dzlh|DI#>X^bu6eBC7Y|+H%bnlV>)0UYOaGy@lWJr`b zB>m#Ex5wa)|9oh>!flGyzkj@Y^3r{q?|3p-L77yT>dlK(G9z7)%jzXZx& z!(t}X%*Zk@Uw^3-pM>1;t``g`9O0RooEV#^Gg7^{fYiv_G$~ZmR{^`+)|C(ugj~V@ z`(Bd~20poGC8}ZzfiO_O|NqM|jn5u@c;~&Cq|lw!+xQP@QM$03{lG_r?}e1#3s^#- z1{Uuj0yYLZ?+x@HJD9vTek>v;`A*3b10Mo;3=tE2BkwrAKIs$=v(wqSI$AnAVPFY+ zi)T-vjy3TS_rxifPa?O(hJNU8@leZ>yk>DBe#0iy5(|CyL1)vFvKYbClA4k1nuE*n zC-2J+ibsY^@x}xX>At!}yItfZ_AVWX{QcKwIwnt*N5QtWOH7_`+iYtSPr9O=x?Pfi zkPt}yN2SeWH>XI-e6g_o20Ca$R9d^3N@S^#SRu7!=rA1=uP8NII?OOGy<2_QTsZh* zDpkhqJh6xl-K6L;b%b*EnILHN`+-MMJ|Xdw~iwJfeWO~?(-D>6Y>q# z5nm%w#Hteita~J*yTwZ^#okahzl^He^huVIjacr3G%>wL=aMN{_^SNs)CLv&%>H}L zhVs-3v#vU<`1prQv&SLSJ+Q>iW*DZ-rqZH5O*nh5n(Ccl0qkO%lRX9hgbO^-fnX#tA$DK0xEq|uOj0h*x}7&7x>xu8i3ar2PWP2@`JeTmwMXH3 zHzcP8x)vBbXNWvp+HIz<;Jiw#Pyg2b2KmOf+x}Pb)srlMEi4s>eD)l6?)Kr~1u<4{fDD&NM zFlWz|%GH+yGV1GvfBsycgs%emrqL1aW*x)IJDekV<*a9VzP>v8+Io6s;&Zf6s4&~p zKvRYQ?@}y&wYxLTPd+PoWAUC>c6w0a;?qMHCH$Yw>UQ>&zpJLe$G504(Cf|ZxK<|} zn(+Ab>P4?BijLw^H80fNoM!_YgQu!;RN}D=g2Yz`l4KKmys;w5oYisp9k&?IcYnMy zR|{QK!0ILy2KRe4-cDJW#e!lL z)S(?ubpkSF5)6zo#`{m4VQcxWB8ujGCbL_06*^hs*YfOr3So!8sow*(=`{%K7+m@> z_lb{ZdmD2W>xB$nG2LK>ND8Y7UEkmE+q5s4`r)Vy;r!J>%7f4GLvAfw?^8a3<8o=W z&hh7LFKPW$bofuJ0JyP>G%M`Pj~b&! zo_!$~f`1J8kfDdX0S*2Dhn6Fr-M-ajUMiz&xga7p-~T6j#6CCr(aY^-uzK;2Yui9b zP?5&Tkl9>zYTRG0zv3uA38GYqypIr%gavs66}7|RL*j^b55hmrN6jWh&k!oI`hyHZ z3>CY{%HV*l$-|1((M#oPnIY3-FA%%OtF|%PQYrBb*}{ZY*a}6aeY_SP((8*b>d)`F z)rWFgq*Ly&v)G7x%!sWK>L9j8C%Vuxl~&T;M}`5dSzE_?fza&-!Z0|RLmRbuerH0-`qucFfnILu<}vSxxRDwibu9ZvDr1o69xYrdA{I za-X;)Os|)OP{~SN-=?1NLrpRp=kQ>)(LtX~`S$jA1U#UaqcRTg0S z$#Jbn8L=_Kl8m?rQ)o{qPxjfaj84%zD2W`M&>&PWIICM|N@#e!0sed-%`x*o?^5h2VIOFTzW@ z?n@!exR?X?@}{=KGc=@Kbd4OcL$n&R7CM$vj}I-q-f5mFS2a117+a4u(e3)hjw*wE8(g6oU* zIR;4m-vfT}aPI8UMbzoOaVkRpOtFNBkOZ+(ly3*u@BbW9yA&CYhBd3l^*vd?Na@89 z5480}J}|pxWi|K@U#(GR`^_aU+SOofo-XO{|5UT1Qju5&R-LY^0MY-H0Jr#C{Rq(Kc260AXfV zB(iaDb&%F91#gWx``%nZf*SYM51dahv*Bl6$h}hXiQ~fGI%?)tT1>WnDgEPA_`p_; zLF>L(q>zfbsoDIlmoo#yTM=vS=+5Rw84@y|WJgP%gGP_>_L6VO|6SwJ){i$!GQqCB0q%lY*)uT8h6o)OylozmT4<1HEJ$BG^`zbl|Wm(?ziXV zlFWH^p^kf{2M9zr%K;TBkr~Wwxw$I%=qQTInNf6L(LklYn$v{6S0eU+CDhqYtcX{` z{H^qSzhQSQ4t&cWmLhJRq~f%Ju0=HNEb_*8jMC30Y1G|D)mZI?40VY`_e1e3+*Yqx zBO4z#@&n7|TE5R#S1cE2fJ4!SrtX*l$`b?Fp-IZCnquCUs-mlMQ^)YtA^)P~UhqqzwspNQw-VB8_L5cd|EQ93eU+Q_k}+3{jEIcmRx~%evR5}h z(Y`v}yg;pKrzdv4Wca-8Abnl}9J8NQGEbcNz@~`ZO~=hy*U3$nS}N=d754SItz8IY z;+%n-Folb(Qr&k+$z(@N1xRdj8*%78JquZ(w{LG=W%n_pJSOCQ#n#VtIUO&ygaKfG2m;yzWWO)=UQU^<4rxr zC@z;?F`~d=b~8Dp!Dk}9If3X+Dv@_@!R7mT!%wIN2Y+vB$?F&{%<3BR&Haf_YnFhG z*Cj3SJ&cY&9dY0z;$P<6zYFlVC{(y6TP41-nw{BI*v^#+pa0o)NY-;BgEo$dHveLt z<4fsOgq*ISpbQCKqI-{VXTRK(bLW?YE$SC#`}tc_g#6{g*WmHtq!+0oopkTRT1l_y zg5~fcQz%u;(ZJCwpag~H~_e=yv z+J+%>NeZxc^UTIke0I~08Q)x5N^YkVSGI@SNGfta4#@qmJkL_PN>w2Y4(GI&VIYf4 zycoFJj`BFJ;6QRXTP-A0d8W{M$3w_VFUlWvCN)ZZpAQX@r2nPDhFz;co3t6;_EF)S z?4Z<$+Yj~UwOv!<+tWm9=7uyIz6FJe3t)lPXf`LZ%I-a0ld%@3`{>Bsp6enL@VAb( z%fxpx%Qn_e==jbW7%fjb0^M$Ix>1P_NLRyyfU!~r(fQfvoQ&aLuH>u3Rh;)oD zpVPsn40+DVG^3jJUuu?@>s5&S_Ti)xX$b6yJ~u|inKrz58~c*c(s_PFC8X_+OzR0n z3T76^@7+vUWoM%w;?wuy#h)i9Zfe0Ikowp6E3C9S*ZQhF?}+b2W<`pJaMkG<_bO4fJ>a{@%fxoc$4+EudL z#%Pg3dm+8YQ7UF=Wli4E&|jL%NDqLG4pQhDBvDKVTRq;cc+OqZoDt9B2Fx*;g-FA6 zbVz6{QUqks0J{DsoC@M@>030PG#WuTS#(%8g(NlYx=VuxtZ3I zV#ceF_pcZK`4j2ut+^n#D7D^E3wJDxG%#Z2a9e00jz9#XS2q#{EKJ7Bd9$SN?chQ? zrLU+SUSEbFp}Cy0WHmo)Tff4UW?mw9OAbMGkctjc1Es9%!-O0FuB`yHw&efMWvET6 zf)^Dnz2+f0V%ZN^NY%~BZ!TAqs}SPP-rJ@37f`2mM-sNj{~({tN!jh}eP4RG@;qSX z@<785p_u8s8g;^98Bf|6F~svghj!dM2H~VI+}3$4#mw zf4#GyA0WL;foQN6O)7D_v1>H)u_6)a?F-LI@S;?YvJqa&jf2!tW?||{5xBJEucm;w zlz=RSt>jILtZVOk3t3-cAeC-}gj*7Yar0VHuf|!CaSo}GOyfL&F?1EDK|Nm}Vw$(d8k24WhNdA>YEM?Z#*L!|9?FUkC zA;%5Z@2up-a*496-o-HnAoup~mjvNenTag^>{oT(8!K5oMY;2g8(H@l;_ z5t6zy)L)*HuA?~?sgj_Z%_Y-G_A1W&q>e(uJS3Iex>dqMiib95oCQzA`|mWc-&jCf)g1BoJ2_TxAmt!Zg_)O8B$+x zgHO~FVai^gl98K(aK5^^y^!t!aM2#N8^s`Nea>V$0uw%l4oY%_NFvCTOCq!QbfZ=1 ziTMA$zi^P@gjt5c2f68+s1j7M3O&@3Nk@Ea#fsc=hyjpZ2wD3>f;#@yxvk^A-v2Kp zbwF`qJ>nh+=M5ON0nri5PLCpC2O(?MW5nhVfBQff9tCu0TboZ2o? z$f^;NzB2nY4XP4ToMzG=nkL39(eE0=K1XNLq|zTy$m(`(_q5v?x3G~hLySe=WN#-b zg&L#6bw`M&5L8){*H(`X)BAG3lNB zF?8aghGvnh{wDohM%~Wm#}}DnJ{YiUBAK>OEdIMHF`rpYxG#;GPGs-)C0()l3B)n# z2g5-03d%6}&$`C=?}_2Ic2h^?FW6YK`OW!KcSs6WICA?Xdtx=8C38A>F!~kNYLecyOD~Q*v#1M1+Z zP(R;-7YJ)Ae=)6XDr6X%IGe;g%LfniGU?}LfeuSct{yatrKlPYr_XwNU$W>+9}9oS zd15l0aBjoHeemd^*C#$nS~7lWFDyLLi_7;0ZDn*lHeRx!jHQFNCk^rpS|lCKCvGwf z?o9ajm%JSdmzAL0o{dKA6SA!jJg^@1$Dh8vJ+Y#bpkfsl0DW;o=JHX`O2o5dqB}?( zx6e?M-^2_GsAhQ^QqJ?=Z59ZoeDb+S;4U8yzoX0DC)J1OmiMhqWZI&}LtV4di11Z! z&$;gOQEQd61nqgi0JS0GPJu|(*Ze4A?CICHi($QE-H7r=+dfcqA)dA?hs&$qfYY)D|{BASZ;_P`@ ziu!rf#ah*B!W*RB_~GPIJT7dYC)|@2_zUM9zSk##%@Hpw-u-co>X#s~laO>gRBN5(w-#a0s-*8(sv84afIa+&vl8A7 z5|0Vgk|ZjLqAclH30&1*n_ir-HR;D%GTD;seius;WE$3(hSjj*{V4zblWN9}g~qgn z?0(mYP+!-qgAhPEmN*&CWkB!YECSJ$fh=-?@YHUJ;Bub&%4({_u{M+lReaW)DTNUg zP#|5MfwmyUwSVj(aMw)BVQlrCF;sdl*FR$~ZFthU*3p4MtNI`^u;xnbrFIhNjM=J; zXKGC5D0r00#B2#rqX?4n;!XZa(L+5AXgdEiB~Wc;xUdJ=A5d zxgTnsv`-0pi(tc$E)KX?*n-Ar1*gd%Z-0&gqlG+`biO18K6;^1VyX^vf-x=p2;rDxrz=-ti-_%RW_ zEM{R8X@4DMZD_&RvIv-rHkIOQGM)vtR2o7}4WKsr%_x{KJizI@=oAu(f>z}k zfuex#F;vV;T3!r9Qf9oxSHPW&^~{m`O0E)zKUCsqK_VCCkJ@}SRhQI? zU0lCjp=EdYa3FQ3z%chTQrKXY>Q338p%`|vhAx@zRsC-{Ql(y4mu7co>ROtG>W(_M zRJl~ti}$Ne_&Y6+K#xfaLGr-4AUuD&cl{{QATw6Xcx4)<2hM@cKYa`n5epN}3|Q}0 z5$Ri@z{C>x~(-7-`|}pX&i6NLzh1y-dKeiF*At|8bGJ8Jl6@y+|C}(ck0FS z>5^I%1tL!nkf6;Da{h-~0O4HRi!{gH5tBK0*Eg0$6&BOn?-h#~BqQ#Q$LHr{jAC>< z4z#RF*HkWkZ&o{cx&%_212_hie13~09|Zo?NT!rq@hkRYoI8&y=|QjMez4iwU=h$` z8xXiVnP0a{s*&5-JF1{OczGg8d{&!t-FAXNw)y}6(@75Uj~Ncl`5w!C1sa$MxlLW5 z)ckoK=g&TMIwb9o`OEj+=DJ46V769{WS|B!Pq`U5dlZ~GaUla#J~Az1$lpKG0_b|G z+GH+JOZJxIGjQTmm&VI;dD7A5S+pkC7(z~x4=<9}b_dFERq^tx4D`0jaCO*^n}@cf z%y0yzGJcWl*KD1~n}LIM(9xWmeF~i}uZH*Ixhm&)Q8sYuU&rF4!8>Y2niq}ZO>Jr5 zEX|m$Ytbu|eND&NSjpPictqM8>vGyIs%`;OH8Me#rj))S<9Gk?5FGI3|31nA^=ZlY z=rs7IC8L4L^ZUK3P+jUDl4uwDcl#1GP9u-pi=Zbfunft*&yy^Vm1^>+~0F#_z?#AhBa2`7+5d5czJWCR_At!7w zqhYXKeog)^zBiWJS^utrN!CSb%}Hc)>@5GzocoV^bl+Wu?be2eGFtM;#+{F7#8RH$ zWuZeI>T|QUdk6Ms6_(N?R3&U^kTj=YOhB{K+f{>K~&BgT{W^RxV|#4vlw+~WE9t2TT! zzQMYrGmAiNcNI8Z>p9O#fU{1mSLG*%qSy(TVnuk*4xt+C1(s`33~rq+9(5b3ESJ1m z=1xkWqrq&4P|}BJkmyK;BDT&(>(Ka4QG%oW*>g` z1;+F9emn#^(>HytvH*Nt#~bzx?|FAMQtj!nC_*CM6E$z6gzZ%A``1iBRfy+ zsD1VLH`Q&jTEN37KW=|~`M&b-A<$upQuR^egg8{fqnYCtf^9-rz{wP6GW-yQ50lTP z*9^VbNE4g`P{rHMY?)WC3?#~bGD-oiWLl(n!lze7=B@$NZM&D+rKS?e%bv=``1$9| z0WGYe)^5zi1r@r%0R^osj_3c_&Lj*ey>; zd2D*e#GtBJcyXtLqVkDED%kwXRrB4JYi4Thr`k!-_7T8d%0T45jKnt#fsZ^Kbj+81 zDT6u46un@$TcjE@*m#`KodJ-~(&I$Kj7lvkVfU9B`{6~w3xsj%i@M#bq?($<)d5F>O}`rMz0mpp@-dhq`~T>eTEM^ z4q{6*0V-pI_lDDNwhphb67&~J5>HcRL^dm)OT_AS{p8fT-`VSQ5*TYySs;NPwBih; z!2dyU#s!XzbmGukvs9w*0>@Cr=06UT=u~pcH5gkt!c)W77=bgdATX+*}1yM{w;AQ4^c?9TD&bCn;oqy5k z?(*MD`*}{uxHY*SLF_U(O_0~j_!e{Ptp0Z-*L~A^TP0GmHqh5)@Uz*etJ&Ew`#~=e zc?DHBuK3DhXKV7>YkHEUAyytk7~CX8mFT z5aecHXTI!#VNs!{ayR<%Ha9ql(g7!wwERA`I}&xLwR|QT_N#pJ;B>^MlAT1QBISq* z@V6@m?LE?#F*?WYRY3yx{e+Ro$ha33rrnjjAmm#Zkmc|P&{}K!yKJ@6j*Qr8e@Z%4 z5@jRW@OyV3=uT4LLIv`|+#F zgY3i#VM5og@;ozFSZUtsG8ndSe5Uekw9HP$K>rFwNpo-K>I{BBaFvee|3c`@g~xxs z&efbas14aTy3*{=^kHtC)m2k=KfFaY2K=t>`)%nGppHZmSkXbt>ak}W5tEvMj;XPd ziK+3(83VnRo!vw`?Iop_sRkIAa(PRP7iuHYOZ;5#6O8DOYil7G$(SZ!H4D=@4k6Rr zg$oPia%$$f&`08*ayfkWs$i00M5L5&+q-)_9B%beFJSfU8A)Qxb2m1}WPob7hJ2H@ zFrXEC;b3;l^JOxq53++W)k7_zWNrgPWKQ2l$2B)$zc0SO=vs48G};n7SZVio^v8Kb z$fStjE;p*2K$Unz--XA{BJ6mX)NA|)yz90?H`H*$wRJ7B6c+M^Lr+dOaW z%s-vJ93th3`q7|XvO6w)(GHYj4;-@XHL=v!R9@0ArIQztol_T{Gw|6SKctG!!PJd1 z1XQ|we3fQsQfj$8I|?do)%Exae}wB z+Ue}xrPAFQAwQabQf{0waA zJl@CzoC6+SuMd^~1ZI8AfNf^La8-iI^6h%MtK;pDDqVL2Ws*sH$S;oqm<5bJCyu^L!mO4={!4lHt7{ z?VhB1AR)2-zOAhYN0Pk0c+4p-r)e1!=r_t|M|_2j=Dm^eAT>w*D|xdc&SDa<&&#

DX#i{8&Y(n*I0WjspPTm4IFJX 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 ChS7uo literal 0 HcmV?d00001 diff --git a/docs/assets/ksef2-mcp-light-logo.png b/docs/assets/ksef2-mcp-light-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..a7f89610dfb550c11f09ba8195054473414b129a GIT binary patch literal 88850 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≥ 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 diff --git a/docs/assets/logo-light.png b/docs/assets/logo-light.png index bccf134f9d1765d09203bd1dd0feccd234871700..1c6e453e84b75f1157d8d9066965a8071a611feb 100644 GIT binary patch delta 12799 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 A?*IS* delta 11910 zcmXY1cRZE<`+lsX$Sgw0R`%W_$KFZiA+lvVM&ZaFAv>E>Au}S`duOj4D`aoV9>4eb ze1HBq|9HKh`?=rueO=dm-B0a0PWK|td&*lHH*Y`?q-l26759(Xt$QiG9fP2R~!40$GVvBBMV3HgehPvKVjaFx1D_mm|5rTBK*tjO2d0cpU#E zii$Yqd~auNY3D(+(4$CeL^pY{n4!7_!bFCIC3e{9NkXSE|C7c#XI|hzG}RxucoBk2wntzD)*a<&L3<1UHm=* z30Z6_5g``%eGiHg)uX-BL{awQ%kGA;dY#7as#G=XW)m8CGbZ(uQ#IWQRX8mO7;(D` za^Ky01+yDhDSw}L861lo`xo^mzDF_Tf}HaXSLx2T zL)=5f5(CHOOM+SfzXe=AS0})+?rT~q=lxNYOEbxZ>a&MMmID@ybDaA(OR8c=@fqad zpPd4NH&bKO>Oo8y0FxFz6@L2bJ9s^6^V($6~li zcAJ##@9bOqZ(CTstw<#$z4>c;rz173jpA!(==W-la&A@P zedIsLf83v+>7Ms)i{qxjA}Iu+^Va*m=c&a%>*ZCSaTG1ytYz$r(^;;ut8@LdQ1_zX z1_?{v9XR)XG1`S6+48D5`mR;Yw|sb8#Y`U}{W;+iqHw(+d?L#m#MGk9w0E*21T!z5 zRj@`AoR~Q05LP1ZVjd~cN&HygWltBq#^|$n&Ig?+nD3Nf`N6%Kb<6e|Z@QguGZw`bvk8EV`F_& zK{6qR`%AU1^v5Gv{=ZmyiDptrNr4!sM{Z4%vH~JA}pC#N?c$ ztN7g)!;x7i=7%>SsM2mdB#E=jwGO6J&qgPcje3Nyr=T(WA;lA0iN^nCs6C`Zu_vxI zwY5`Z_WCp_7Nj%$a|J5goXSY_<;Bg=h59NhwFm6jg(tFj9T1jLUx4q z?KgBNIJ}O6^o2x39_X~Sr1&IQc{guLhVT#7u4;JE3z4B|#SyQ52mU?B%aFi=80bDx zKKK0XZjVMYvn>$vo;Y!FShh5o2A#R?|2eXS^lR_49XPc zoJz<;dg_-p1}n-MDsS->a!Op5EeT+PjWsoP^|X?gjW0C6>Ytko<`SBy3)gK@_IF7m z2Wo7&>B?k?6*eDV=`|tX4^-RB&x_gXtE-q zw{A9C=Mm;qNPF?K8Vk*x+AzxgmpF45tYdE{1l<(pth?Gtbv0PYEi&*Gbd*)sM3J;C zi`j|4%F7&1(*#O83=Q!4J3nyU>&=wI$~iK2^2Jhvj&4tEUhcjv$47w!`uHFl zx-6litiIysj{_k{b|$s4_&^`UzHMzNyi$>z+B7;rJDUGjJbNPRS>5w`qia z_A7sejhTzT(CeYt<6bUylQFIsAgkstDI#T!+jt3Untb0l+KSRjlZ&}8zPHsdGT8QV zR(cT5nm&tg|GK}Hr69oOAsc;IUHEsnX%}sBaZk0qDL};R`xLx7>`AUndcQSpM91b6 z6;!?0dJ0G6MYZy1Pa8^zqX@k__mkaHK6$nBDNY_H zdEzSfB$p&2xm`jBbqTJt47ri^{C(zCGg-0~DYGI$pt6s9z84!m-EBIVXLhPPXSi%1 z=vqzl_JUTG>o%>0$R5RHh`H)cRL)19NZ~^P-Xb3h3ZEo?vfKY5P>^S5V`s;`@4rY zq^E5YAPi!MAd=&e+sMa;F-xYT3i}h@7rv1w-{M01jm~<(SLIlgxbi{)Q90j>U8wKB zK4?uPS6aGlhw(9a4s2SqZKMltr~zKm1H63Toi)#P z^GP-cy@Io;zz+R@=5WM=*71inz<3kps$~UXxbA$ntNNvwJ!~>O_qbx`?xu95m?Ru~ z&Q~%T+qJ_IDLmSCXzjs-V)@1vZBkKNyzv24zOnApuY`!-$oomTRTcd^fQd3Mynqfc`X-Jl-E9(iC0`h}w>&7_B5 z@dC7neC~;*yZ6u9N2?qQzfW?jXMeE?As~NeEC`tcSLG?Le@f9IA{rA_*HA^o7|#?p zQjyW~&E-?>XF#tmq}#;?h>xXTu73hTx^E9laUVA6(cQXC>0eOk>?aM^w~h*-%M$r^ zoAfT)A2B*IzwN)k)M5$O-9Ao4-FcUBy0_u7M0(!(*VS8q>sGQ<#lFks1mdwa%I~&! zCh}U)WA!q%;k6V;s`|~58w7!}r-uUZ;kKIWR2k&bep?@2X!=Hyf5P(?E!w8ZT=12R z4qoYL8J+A;m2Bm_$%p~~R443bxU8&`m}LO~XazUad@WA7-84%A?--V&U=H3OZd|`~ z`{ml}wkZ(}hRB0&4+zvIlLbk1D8~Yj-jij{W~^SG^H;EE7TLV@mKswZyKvnth}goX z0AEtvsv-sipYSNGsC0YwG;5)4rz6WwhwRo371gy9wM2*!W#4qp(Kd8gkr&PJ$H#Zp zzOQlrYp=ROoXrg_Iw!IcmBGV6JE!K=3)~QNv(L~@qoQuB+OqbjqoDvRq5^3|PJ#zP zzl(v$;Z9_>6C)2(`>%JUg_V zVY2fK5D}0PH3|##GZo3+N*~B)HK3a|KsVUPNSOXyMi{q^*v@w5-6AJhB3ozN{mt}1 zc?6su?Xi`^$l~FflJfJ)zR8CCRux>hPC_plSLClcnRBM3D`yyENn3-P-&*Q}&o6bb zuXG)!Dk5qNkNSbm45Z!HY21Ze zBU}~Lh)~ymqVZ*v4IQuh^lA7_c|iyY*o`+e=y9Vx&R~zM?pRvYrC4Ut(<__*kP>QD z_t2jA8BRDUsE^?Og&kx4rt@s3P>YCC+dfzIhAi`6DI3BiRPU&VOv^ddZ17Jf$%i|q z$d#N-)~9_H1=;HRRq}+!rM(_J>JUbz&(4}9$zOG+k_T1fT|T!k%#b$HWhP^F$lp26 z!Szkay!+UBT<=tSZf0Fdn<=L0*`Z{Odj&3IZDhD)oXf9K->t*&7b+L;$bTaLc$>pP zosoRz{%Kpcc?X^}I;L#(mmnk>IZM5eZ;Rtsr!Z-_UYW#gm zAu(W=iN$kco8w40%TJ98-G<~7N$sw&Err1@BMO?#sqTMtYHIN=UP&Ddv^)*C+nyq- zS#awlV>f34`E6=;(l#wV@A1J44T66Hu8E0ahjOMA3hkimM701Me?hS~wz_5r%}J-r z!Q7aiK1~)7!%?C(!T}nDj}Fhf%?nbcVP8Pp7rdN=dqR`%huD@ey?{l z`t&(ob)4N94;Lj!0Oa+hH5QMsMvjnk_I1uy6_`^gGLxmkvm`pgn21X7%r+nk?W)0l zg7?qHe)fF)t{hJwhYDX3O}ifYAV%0nTgJh`E=wA)d$3G|Y+*UHK`^7_V1mkeN&jqO zeDlIr+HaWhUxtt~n>DYt8cR^xR?Z^w^I)|0%-($g{tb0^XkHWd@qYN&5#R9U_L1) z+lqG_w61a@c`h;+el%4O9t-nrnOR)s-b-)Gv|Q^Xf$KVpcpw7ux>M{TXQmkWlMRVA zSGev8@Da{c0uLelI{?fT)%eQ9h&G<2+S11rwD*m&(vOq?U}^yNZ7;BI${*oBhC`T`PhCEGUN?pjlDYC)MQ+jYr;rR&F;XY+`BRk~INjQhKfTG8 zHfQCta6fsu5a@OeigV%gHYVy?VMTqb^g`IvahI=M*j+)^Q5}(@e3u;>AElhPvdxxw zL?fwPxWSZR3@d&6F~YQkCV$TlS1xB$7oT@B{|+yCTxxhN`S4(bEg5N?In|#JAi8Vh z;_a8y35Z*LQKT`HX%SXIgl)p#IK4s*j+ooNhTRVpd%}6n6Z+vL3=E*Ys_nQ<`|hXz zj9o#Z*uLQm?E3i7E*X=!fZ27!W()2|OHM;tMNmBKS$@WagkD{#wC z>fTeQ7a@PM0!v|65qz`Q%@1N3_xFMnpn=#9i-)VN>H?&2&!a)@wxP*bI=7&5E^Ycn zmOgXM_jAkMxyj}=X#h@8QXcmmy=HTZdZ{#$ob{M;rRAM!OWSyy8bXjQ^5l+#z44A^ zV~$4U_gH@W;`)~AN0QsY&Pt}UczRJ+K~Ei&1HjCc zwc+d2KR!DMITHN#|1er!Pfu5593# z9uu|^wvc&3NUkt*gN^BGo+Xp_b`MdH2d?hR!S1@V#(rwLnBVL?MEXeljLJ(OWTSoQ zbH&iNUsld}ZP~o)D>EUJ4WQq0?abK&eqNL7pub)?qm{tKQA@@*f7M5$KsdOl)RGoY zt|e1zPNLczf)V>9HKFR%{?oTPI5=)%gYY3PR%F9heg%?XKgq$a<&ZqiKAvRmEJ0i) zYHrq<1uR<}`@SguB8QDlv6wF+C`AtzX6B(xnWhb&iaLB;-vKXeT!va)xlb)YH~4|a zabX`(lm+v6KuW~IsI~GZj=7KY^-f31zoy1FvgV?4)?(7#ZWs4Mmo<55y*`-#2qNqg zBteprw$a*zi=8gZ@Y9p>-69pj_Z=XPXrv8@lS}bGHM>4Ocn*Vw<{qD)Wg|Y;ncFrf zPu6YJP7DuK!r=N`p?PT@8VkE8%NngjlllB8_V?_nqVKn5;g?Rc8DpPiRAtq`i}O(b zp$0^;#$9w*tr7F$%^_+)bed9h=p#jAzO_%=2!t!gzOpI& zP`U`1b;_#d{*tmhXEUn?_8&1g-i*lAe>c|%kW5W^x&J+)P((ZC% zBQRjF3LiW{?|cSv)wQ977xe4Sf7d%O_zqQ~#;o$1kq+NJEl+_lZqJdbK#sFTn;Dm)YZwihq}F zrX_;4=sU>XL(uk61<_r6Fu^rFU|~QPULtq48@oI&U+>QO-LS_T-yGXfvGv>g!Xy$! zt4;CwxU;@8NVJ#4y~#@%8al$QAJ8R$^Q`MvIfjK&4BRSKRAipq;zAGAVUc1b3;XgU|r;~K-SS9{`tL}~N%&o^RWk&Q*h%hJGYJn+| zEaDYAT+;5Qy)0)N$STooV3R!C_FXsKY8e&>X2|7w_}>nsECCd7qIVH)R^_=d%=Wv# zi@(pswRE?B;3INEQ%Zue_(%kWF{YtP{94TPAX>s{&USzoh%}l zlxc&?NYG)?Sy6LvHs&4#?XP}LG2`n-BV@sg0Q~2}t3Q3{?pDnm68q|c;=c=H$g)ej zm&~*<-RqwJN`c7swl_0mdtP{%^{9>a3~8xBv$W!KVWdRf_BBz;Z^qkVDil5P*^nI@ zBB4LpnYF3EYROHiQ9E5+(b-WOUC^El-Bna3`;KnphfOw;J77 zA^)?WgmZ5~veSQe-=-aMfmro`lNq>tavBrC6v=fD;}n52(^>ltqCQ8l5y=tQMqxBG zQUFZJP2)n_RkTWIjRI0QiWy@KMZ>m+?VF13DS*OBZUV7h5a0;eGTE!%dj5^`cqH{> z_VrX|0!t2XF2SV%KWQ$xW`zKUC8uN<0dF~ogAm1r2$Qjqgre|6O^DMTIPoGbY=snN!~Kw99WrEQe`hpaFt%{s9mo$=|3hUa#|T zoFYvOZ%o{oUC%3e-Dv%-?)>8oNOsG;%z#qNjW**M@J_q6_kQ^Ur4xt*UP5`F?v{k(wwp>yz+^=K*IbRF z=^q#R9qCyHba%o?|3982jMLo(e1raxa%1PogDQS|n3?AaXXEKF%4{@k7B0Bfa4AgH z{Ux6EQ^9ifC%2cCEZ~BteKR+n{aSlAEB;@9LlBG}be=2sZ$y^r+H0grQ=y~8S846X zF5GF4@w9|#E5sye!GE^jXyYyDu3PRu>#g$B##z*;8q`DncH#Z97Z;sPedFjg{(pH2 zX9W%!`Yj;&J>?HgP5?%JjE>yGj0X#&U3Wg>Ld_ldg||vLXRl5MBK1I*weMD$`s)Hz zl`iV_qQFB2_5XjKNLYYut0c$DcEA4S?C;{mO9gxPd~dHl6&|XQevJhH!9=pZU!&5x zZ&$#fFC|)@$d=e%HD%?De%i0ZX8eZ99+(5b9}FuN7ur^8UPZs8I!naJUsa6!<;1wj z{DjBofyg8g%=K-$ahWl(AwAap+@t5eQ3~bw!FR=u$>a1YS*4m@be=q<4J-5g??i|T z&l!X!Fz&B4&SYFf$sQRCr%Sy5cWdlXXEk) za_%ucu<~!q{4dJGV1OOya^Pq4_4yf_DMhUPha9shLXTedNRFMv3fEV9uGd!F(9;8j zIiS|E5uXsd^ZJXvh6)zokNMoZPdO+5aKa=wr?p*WM+I@Q0t#;42&#M~-w{^dBZ-dz zLw~;Qm4Sy=h4d^OA#nivUd;5}ybj^J>L(ceSdFN^asnFxaO} z@w-0NzMO^$Pm3;{BngOJ8dr^-8x>*IV2T|09nppu2uZbm1`_qL)W5p%dh{jFUcArN zPT@Z#P!Avk^8Z3;tS`54|AFK|@R+^9EV=0PV=Dap&iVBlu3L*}tj9+F*54t_dh zAcga!kt=B2(Zi#OMSoSlD|`OkebVW2_gY-4&cDxI#vZDCXP>=k$XM|~+++W8CE3B| zbKad7+IY}a0?2{yWv3!X`Ac&R6*<00@O;@fA-U9$FBbry3NhqNioWCY{?wm{B~glq zQeZP%&2t(ZHUe#ZcTapjc-8C5WTg)#m>k}Xu2FJ2`MaONa=1-E81%*;+78LHt86tp zeQT>y+t|5Fkb6_MuZYXoeie7HUZ%cwH`Ql!hn~@#z#7WoNin`UY>YLf{5ip=<$Op7 z1B*q-c9-`V5+}eJsL3!e)Fa*;@4TVZdZd&s!KUU?W^(@fnhr3R(%*>qxI>_en>}* zU5YDh);DZhXT}BIyD85fkd-`e!*D+Z2CYs+kl-$UHvuhw306~n%So5nFlK1u;6d5#m0&p`|JZko zP>DC!8saJ;{49LpQ*Lf2Q|zjZ1erE#f`}3X-RV<-$vwkkXuKb)nUzG-3OEVr>55v< zJuBJN%)JEyYDNOZ18N*7+AiL|9H|LPj!Dd%*W`|;Q%CK99j5eCq%LAEl@A5aW(ELG?9gVDNZq*WA&x?JDq#*7r`|P5uaI(+snhKMDRZT^G ze>HFxLhUJ`p>5Ch>&}qGF!x3IK}pl2TbSB_d}S~RkpKvw7i#X2De|w;LXL!lx%Xg2 zbGOIk;tHM~RVEkht`Bfj7KXR-sZ=IG-lmX0KcpIh>7vkS=f?^0^ktC_ z4uj`dGI#$Mmfoprg(Z9LnGdN_Y&i2wSZH2_WV_Q)4P^9Fi!MEz6nzYx1<(_ig+jmI zfTc;Xu*Xs)-Y9+d3&Z+La!1TYxu18-%=3A1{av8}4C6q<#m=?eJ_uU+$OUMX}O7o=o<`pN_Ys4(ps5kQ zu>ZlzV*cX>$tYnV`sl~eY0usp)RZ{#5?H4O&+mhW{>EPDBZe;6fgpQH2uGTi`}H~k zK?ZEH*8AkUR#Z+e9SzOJBTaq)vJW5FX|UcN5s-J1PRf>YSB>lmzQo4j2DD%o;&u3t zBX?=GPMESOh!Jx9d$vc@Rs~?`+y3ucvaf7yMb*>k=1;%au752+n<4jb|GW1}TFkvG z6-`%KgqMDyU$K_{MJ$AhvNG(|q(3L&Q-A5WK7z+S;%0wACqsIw5s|5&kreeOuhY(e zjw}bUR;wA}phnZL0#_V&h95IVLhWH?p$^G0vwjP@N^~CFX-uT%2%LZk3PRyg^g)+2 zWpRXpO_>+ie*iwZ<$_NN4fK?96`gOUy*^p+5qJvdNHpdnSol={3+6f4#$YQLJqJ5` z@?mg|&YC-DlFQ-8(asq^cU4udU?i3; z*0q@OG`xx~2)N!*!1G`L&Kcjp(&(GmOiFD^nbe|*BylXgQfkkuym$L;bZntwreL;< zV$``hJ*qK%h}!fb9X=0B4}FxoVwK_{2$%3#6ukqsOBpYCl{X)G`ZA zgmi)=oATHf!~oU>uJ$$>ZG3txrmK7q5?e+ z#VO8!T{2K4X%$LM`-*5Ls!tf4Zig_{Sr05udEmU&0^748Jxv^v4`tt+tmYZr<|%8! zxV-@c*e{23~c=<{u_B@ zw=kLERt8?NmgnTGzUkENec(ni=m>+{4;WUiyaemf$G;JD3UN_OTUVZ1TZ|~;J0h1) zlG;MzM!THh6)rLD6+bv`ZX@M&b;(QS58CljVy?Xc5~^nMq>yO7gR--K_&_7*@i8Vn z0+Ey}H`%82Jz>#Gwovn-kJI#%(lYm7oADan{u;)QDcda!ZEzd>C%QY^f2uH5tFV#> z!czZZQiJPMz@#2Wp%f-F+1L6JYo3gZ4k>D-I?F?~NM+rn=K5knfx#ug+xyEj&YvBZ zaeK-g4(&M=@E|T1Zp01?%Gi@^xZvHzl1|$26 zEFxH?oa)wxPvEB94%bO23 za8DPY2zn^Gk5V1kJWxR!2%Cy8Hq1mCB+D2yAsn5@!lkgvzP$}+B_Wk+aKwooaEZ}v zZRv>1OVF>oH+c7}7h!vC*%5-23nL%!B(!lMqf^P(k>PnHq^SvY5w&@oPoHQI5BRvu zSP>8q+{ES6m_!&Yap_tqo`s7T3TMo!fxQ6)Eka=U8$y9eUc7s#mMa4(=1SQ1LhYSx zUwXPfL7VHjmm_!M4Iv9-plO4`JxNGeJRAO4>)oO3>Cbl>NR=23td@BA-1lO?t8+;r z``qL!D|Z)>EEIkh%Ngh%JlHdBvzntm7RDf(72?{34YhK7n)XO#QT505>1i;Bk*N9? zMpVBl%w-BO!L-v8BFCYej`3v5S!dg`qNoSDP72X$eVJ0*_Q}hp#nY9G!eh2HK#DuS zJn%{50FdHpBxTx*o=neWmZWIXUPE0K-cdwGVdhkoul*t(eakz>OF;O5*dORY3=@7; zFiSs{;k9=cf-JNq3?kVZ)s}L3-L3{)SVm!`4u1^y!;y@v%=9Gc_ng5u9+8hQLG2UE z3qj$LwUKw5hKT;j?rh_k-jI15bUp&oJ+ptuZ$Eq=wPR<%sw}#GaoZ%c2fg z1`*M?sa21SVR>WI8zmMNKKn62A79*a-4(Th_f~b+7}Tbt9(alhEC~i9fWKi|OXJsy zM&h!;(lp_3my>f@?uW|pG#%J1Emt31N2UDaaSycX$(naOBeBB7g{GV-0P{<@Ip#y8 zLNb2?0^wP4zd$ar&u?yN&bw$fBFKj<_>%m#hWRuV`@IeY* zkuW<-Y@gSezo~scUgEd=q6UUspp$z5N5QM`f_=(Ht+;$F4B6SUu;O5&UV&Jf;0mF- zk$-aZM91=x$#w#sd+S5fiu&gL#Yd7V#L(Wyfn0*xQq6J31p?I{>*)IlJ8H@*>oL6B z0p?U5f0S{Oq|Tm^^BBr3u#D<*vK8O-KC~Lcv|qq(kc1QXb?sbnIiAoHg6lFiJzpM5 zNKGQ7b+iVc@h3x`e~s{{3#)uvYAWZUnFG=<@Xz4 z!J(kmbJ1zdM>Hq|gu(_e#ot=~BN^zeZ@TYw9ZXsv3+P7PJP z@zAJi8%o?1)N54NZOzmz+i_^z)c5+zN@5nH>lHIXXbs;rz%9NOpf8g^?p7l zAJlr?+{wnqmcXvk&byD8df3O`Q=vZCv=@JM^ah{e`TI0OQ`ri}eci+sOu;$;x?Ce{4MDh6sUK7ptqSx$^~0YOF~ z{X3dMgxfdZljjMga3?Coirmef&|EgEP$yQn$a<|cKbV|6G9#ML4MbQO3_AsRGcO>%XY0cgmAXlp|EF2Xn5$^)qz^AyC3&vzXKU53oGkL z(&f3|Wka?(6&dc8@v3gjQj=@xn2O%`uA~=rsGRdPVz+uAle0^SDmmePXwv-{M{BC@ y5$3;GLzf$07#z_P_BC|zZl!y7|5t>IE!X%Wtd866{sb^g-c%GcVWm%A1pXiU(`xAe diff --git a/docs/docs.manifest.json b/docs/docs.manifest.json index 6b8e358..a9f3a5d 100644 --- a/docs/docs.manifest.json +++ b/docs/docs.manifest.json @@ -1,7 +1,463 @@ { "product": "sdk", "slug": "sdk", - "name": "ksef2 SDK", - "name_pl": "ksef2 SDK", - "min_pages": 1 + "name": { + "en": "ksef2 SDK", + "pl": "ksef2 SDK" + }, + "default_locale": "en", + "locales": [ + "en", + "pl" + ], + "min_pages": 60, + "sidebar": { + "link": "getting-started/overview.mdx", + "categories": [ + { + "id": "getting-started", + "label": "Getting started", + "translations": { + "pl": "Pierwsze kroki" + }, + "kind": "tutorial", + "items": [ + { + "label": "Overview", + "translations": { + "pl": "Przegląd" + }, + "path": "getting-started/overview.mdx" + }, + { + "label": "Quickstart", + "translations": { + "pl": "Szybki start" + }, + "path": "getting-started/quickstart.mdx" + } + ] + }, + { + "id": "concepts", + "label": "Concepts", + "translations": { + "pl": "Koncepcje" + }, + "kind": "explanation", + "items": [ + { + "label": "SDK Overview", + "translations": { + "pl": "Przegląd SDK" + }, + "path": "concepts/overview.mdx" + }, + { + "label": "Foundations", + "translations": { + "pl": "Podstawy" + }, + "items": [ + { + "label": "Environments", + "translations": { + "pl": "Środowiska" + }, + "path": "concepts/environments.mdx" + }, + { + "label": "Clients and lifecycle", + "translations": { + "pl": "Klienci i cykl życia" + }, + "path": "concepts/clients-and-lifecycle.mdx" + }, + { + "label": "Glossary", + "translations": { + "pl": "Słownik" + }, + "path": "concepts/glossary.mdx" + } + ] + }, + { + "label": "Authentication and security", + "translations": { + "pl": "Uwierzytelnianie i bezpieczeństwo" + }, + "items": [ + { + "label": "Authentication methods", + "translations": { + "pl": "Metody uwierzytelniania" + }, + "path": "concepts/authentication-methods.mdx" + }, + { + "label": "Certificates", + "translations": { + "pl": "Certyfikaty" + }, + "path": "concepts/certificates.mdx" + }, + { + "label": "Encryption", + "translations": { + "pl": "Szyfrowanie" + }, + "path": "concepts/encryption.mdx" + }, + { + "label": "XAdES", + "translations": { + "pl": "XAdES" + }, + "path": "concepts/xades.mdx" + } + ] + }, + { + "label": "Invoice workflows", + "translations": { + "pl": "Przepływy faktur" + }, + "items": [ + { + "label": "Sessions", + "translations": { + "pl": "Sesje" + }, + "path": "concepts/sessions.mdx" + }, + { + "label": "Invoice lifecycle", + "translations": { + "pl": "Cykl życia faktury" + }, + "path": "concepts/invoice-lifecycle.mdx" + }, + { + "label": "Querying and exports", + "translations": { + "pl": "Zapytania i eksporty" + }, + "path": "concepts/querying-and-exports.mdx" + }, + { + "label": "Status and UPO", + "translations": { + "pl": "Status i UPO" + }, + "path": "concepts/status-and-upo.mdx" + } + ] + }, + { + "label": "Administration", + "translations": { + "pl": "Administracja" + }, + "items": [ + { + "label": "Tokens and permissions", + "translations": { + "pl": "Tokeny i uprawnienia" + }, + "path": "concepts/permissions.mdx" + }, + { + "label": "Limits, polling, and retries", + "translations": { + "pl": "Limity, polling i ponowienia" + }, + "path": "concepts/limits.mdx" + }, + { + "label": "PEPPOL", + "translations": { + "pl": "PEPPOL" + }, + "path": "concepts/peppol.mdx" + } + ] + } + ] + }, + { + "id": "how-to-guides", + "label": "How-to guides", + "translations": { + "pl": "Przewodniki praktyczne" + }, + "kind": "how-to", + "items": [ + { + "label": "Overview", + "translations": { + "pl": "Przegląd" + }, + "path": "how-to-guides/overview.mdx" + }, + { + "label": "Migrate to 1.0.0", + "translations": { + "pl": "Migracja do 1.0.0" + }, + "path": "how-to-guides/migrate-to-1-0-0.mdx" + }, + { + "label": "Setup", + "translations": { + "pl": "Konfiguracja" + }, + "items": [ + { + "label": "Set up clients", + "translations": { + "pl": "Skonfiguruj klienta" + }, + "path": "how-to-guides/client-setup.mdx" + }, + { + "label": "Configure a certificate store", + "translations": { + "pl": "Skonfiguruj magazyn certyfikatów" + }, + "path": "how-to-guides/configure-certificate-store.mdx" + }, + { + "label": "Authenticate", + "translations": { + "pl": "Uwierzytelnij się" + }, + "path": "how-to-guides/authenticate.mdx" + }, + { + "label": "Use profiles", + "translations": { + "pl": "Użyj profili" + }, + "path": "how-to-guides/profiles.mdx" + } + ] + }, + { + "label": "Invoice work", + "translations": { + "pl": "Praca z fakturami" + }, + "items": [ + { + "label": "Build FA(3) invoices", + "translations": { + "pl": "Zbuduj faktury FA(3)" + }, + "path": "how-to-guides/build-fa3-invoices.mdx" + }, + { + "label": "Send invoices", + "translations": { + "pl": "Wyślij faktury" + }, + "path": "how-to-guides/send-invoices.mdx" + }, + { + "label": "Get status and UPO", + "translations": { + "pl": "Sprawdź status i UPO" + }, + "path": "how-to-guides/get-status-and-upo.mdx" + }, + { + "label": "Query invoices", + "translations": { + "pl": "Wyszukaj faktury" + }, + "path": "how-to-guides/query-invoices.mdx" + }, + { + "label": "Download invoices", + "translations": { + "pl": "Pobierz faktury" + }, + "path": "how-to-guides/download-invoices.mdx" + } + ] + }, + { + "label": "Administration", + "translations": { + "pl": "Administracja" + }, + "items": [ + { + "label": "Manage tokens", + "translations": { + "pl": "Zarządzaj tokenami" + }, + "path": "how-to-guides/manage-tokens.mdx" + }, + { + "label": "Configure permissions", + "translations": { + "pl": "Skonfiguruj uprawnienia" + }, + "path": "how-to-guides/configure-permissions.mdx" + }, + { + "label": "Manage certificates", + "translations": { + "pl": "Zarządzaj certyfikatami" + }, + "path": "how-to-guides/manage-certificates.mdx" + }, + { + "label": "Manage limits", + "translations": { + "pl": "Zarządzaj limitami" + }, + "path": "how-to-guides/manage-limits.mdx" + } + ] + }, + { + "label": "Utilities", + "translations": { + "pl": "Narzędzia pomocnicze" + }, + "items": [ + { + "label": "Inspect encryption certificates", + "translations": { + "pl": "Sprawdź certyfikaty szyfrowania" + }, + "path": "how-to-guides/inspect-encryption-certificates.mdx" + }, + { + "label": "Query PEPPOL providers", + "translations": { + "pl": "Wyszukaj dostawców PEPPOL" + }, + "path": "how-to-guides/query-peppol-providers.mdx" + }, + { + "label": "Use TEST data", + "translations": { + "pl": "Użyj danych TEST" + }, + "path": "how-to-guides/use-test-data.mdx" + }, + { + "label": "Use XAdES helpers", + "translations": { + "pl": "Użyj pomocników XAdES" + }, + "path": "how-to-guides/use-xades-helpers.mdx" + } + ] + } + ] + }, + { + "id": "reference", + "label": "Reference", + "translations": { + "pl": "Referencja" + }, + "kind": "reference", + "items": [ + { + "label": "1.0.0 release notes", + "translations": { + "pl": "Release notes 1.0.0" + }, + "path": "reference/release-notes-1-0-0.mdx" + }, + { + "label": "Public API contract", + "translations": { + "pl": "Kontrakt publicznego API" + }, + "path": "reference/public-api.mdx" + }, + { + "label": "Client lifecycle", + "translations": { + "pl": "Cykl życia klienta" + }, + "path": "reference/client-lifecycle.mdx" + }, + { + "label": "Error handling", + "translations": { + "pl": "Obsługa błędów" + }, + "path": "reference/errors.mdx" + }, + { + "label": "Operations", + "translations": { + "pl": "Operacje" + }, + "path": "reference/operations.mdx" + }, + { + "label": "Low-level API", + "translations": { + "pl": "Low-level API" + }, + "items": [ + { + "label": "Overview", + "translations": { + "pl": "Przegląd" + }, + "path": "reference/low-level/overview.mdx" + }, + { + "label": "Authentication", + "translations": { + "pl": "Uwierzytelnianie" + }, + "path": "reference/low-level/authentication.mdx" + }, + { + "label": "Sessions and invoices", + "translations": { + "pl": "Sesje i faktury" + }, + "path": "reference/low-level/sessions-invoices.mdx" + }, + { + "label": "Endpoint map", + "translations": { + "pl": "Mapa endpointów" + }, + "path": "reference/low-level/endpoint-map.mdx" + } + ] + } + ] + }, + { + "id": "contributing", + "label": "Contributing", + "translations": { + "pl": "Współtworzenie" + }, + "kind": "reference", + "items": [ + { + "label": "Sync code generation", + "translations": { + "pl": "Generowanie kodu sync" + }, + "path": "contributing/sync-generation.md" + } + ] + } + ] + } } diff --git a/docs/en/concepts/authentication-methods.mdx b/docs/en/concepts/authentication-methods.mdx new file mode 100644 index 0000000..066e023 --- /dev/null +++ b/docs/en/concepts/authentication-methods.mdx @@ -0,0 +1,221 @@ +--- +title: Authentication methods +description: Understand KSeF token, XAdES, TEST certificate, and profile-based authentication in ksef2. +--- + +import { Aside, CardGrid, LinkCard, Steps } from '@astrojs/starlight/components'; + +Authentication is the step that turns a root `Client` or `AsyncClient` into an +authenticated client for one KSeF context. The root client chooses the +environment and owns transport resources. The authentication method proves who +may operate in the selected context. + +KSeF separates two ideas that are easy to merge mentally: + +| Idea | Meaning in KSeF | SDK surface | +| --- | --- | --- | +| Login context | The taxpayer or entity on whose behalf operations will run. | `nip` plus `context_type`, for example `context_type="nip"`. | +| Authenticating subject | The token or certificate-backed identity proving the right to enter that context. | `ksef_token`, or `cert` plus `private_key`. | + +After authentication succeeds, the SDK returns an `AuthenticatedClient`. That +client carries KSeF access and refresh tokens and exposes protected branches +such as sessions, invoices, tokens, permissions, certificates, limits, and +session history. + +```text +Client(Environment.TEST) + -> client.authentication + -> with_token(), with_xades(), with_test_certificate(), or with_profile() + -> AuthenticatedClient for one KSeF context + -> online sessions, batch sessions, invoices + -> tokens, permissions, certificates, limits +``` + +## KSeF methods and SDK helpers + +At the KSeF API level, authentication has two main families: + +| KSeF family | What is sent to KSeF | SDK helper | +| --- | --- | --- | +| XAdES authentication | A signed `AuthTokenRequest` XML document. | `with_xades()` | +| KSeF token authentication | A KSeF token encrypted with the current token-encryption certificate. | `with_token()` | + +The SDK adds two convenience layers: + +| SDK helper | What it adds | +| --- | --- | +| `with_test_certificate()` | Generates temporary TEST certificate material, then uses the XAdES flow. | +| `with_profile()` | Reads local `ksef2-cli` profile configuration and dispatches to token, TEST certificate, PEM XAdES, or PKCS#12 XAdES authentication. | + + + +## Choose a method + +| Method | Use it when | What the SDK does | +| --- | --- | --- | +| `with_test_certificate()` | You are developing against `Environment.TEST` and want the least setup. | Generates a TEST certificate from the NIP and authenticates through XAdES. | +| `with_token()` | Your application already has a KSeF token for the target context. | Loads the KSeF token-encryption certificate, encrypts the token with the challenge timestamp, submits token authentication, polls status, and redeems tokens. | +| `with_xades()` | Your application has certificate and private-key material available to Python. | Builds the KSeF challenge XML, signs it with XAdES, submits the signed XML, polls status, and redeems tokens. | +| `with_profile()` | You want SDK code and `ksef2-cli` to share named local authentication settings. | Resolves the selected profile and calls the matching SDK method. | + + + + + + +## XAdES subject and context + +For XAdES authentication, the SDK builds and signs the XML request for the +context NIP you pass to `with_xades()`. KSeF reads the authenticating subject +from the signing certificate: for example a company seal certificate, a +personal certificate containing a NIP or PESEL, a KSeF authentication +certificate, or a certificate allowed by fingerprint permissions. + +That means `nip` is the login context, not necessarily the identifier embedded +in the signing certificate. A person can authenticate into a company context if +KSeF sees the right active permissions for that person or certificate. + + + +## Profiles are local authentication configuration + +`with_profile()` is not a separate credential type. It is a configuration layer +over the same authentication methods: + +| Profile auth type | Direct SDK method | +| --- | --- | +| `token` | `with_token()` | +| `test_certificate` | `with_test_certificate()` | +| `xades_pem` | `with_xades()` with PEM certificate and key files | +| `xades_p12` | `with_xades()` with a PKCS#12/PFX archive | + +A profile stores non-secret settings such as environment, NIP, auth type, +certificate paths, polling settings, and the name of the environment variable +that contains a secret. It should not store token values, private-key +passwords, or PKCS#12 passwords. + +Profile selection follows the same order in the SDK and CLI: + +1. Explicit profile name passed to `with_profile("name")`. +2. `KSEF2_PROFILE`. +3. `active_profile` in the local profile config. + + + +The root client environment must match the selected profile environment. A +`test` profile belongs with `Client(Environment.TEST)`, a `demo` profile with +`Client(Environment.DEMO)`, and a `production` profile with +`Client(Environment.PRODUCTION)`. + +## Authentication lifecycle + + + +1. The root client requests a challenge from the selected KSeF environment. + +2. The chosen method proves identity for the requested context. + + Token authentication encrypts the KSeF token together with the challenge + timestamp. XAdES authentication signs the challenge XML. TEST certificate + authentication generates TEST certificate material first. Profile + authentication resolves one of those paths from local configuration. + +3. KSeF starts an asynchronous authentication operation. + + The SDK keeps the temporary operation token and reference number internally + while it polls for completion. + +4. The SDK redeems the completed operation into access and refresh tokens. + +5. The returned `AuthenticatedClient` carries those tokens into protected SDK + operations. + + + +Async clients use the same method names on `AsyncClient`; call them with +`await`. The choice between token, TEST certificate, XAdES, and profile +authentication stays the same. + +## What to protect + +| Value | Why it matters | +| --- | --- | +| KSeF token | It can authenticate into its configured context until revoked or expired. | +| Private key and certificate password | They can prove the identity used by XAdES authentication. | +| Access token | It authorizes protected SDK calls while valid. | +| Refresh token | It can mint a new access token while valid. | +| Authentication resume state | It contains access and refresh tokens needed to rehydrate an authenticated client. | +| Serialized session state | It can contain session encryption material and batch upload URLs needed to resume a workflow. | + +Keep secrets in environment variables, secret managers, or application-owned +secure storage. Keep profile files limited to non-secret defaults and the names +of environment variables that contain secrets. + +## Authentication is not authorization + +Authentication proves identity for a context. Permissions still determine which +operations that identity may perform. A credential can authenticate correctly +and still fail when sending invoices, querying metadata, managing tokens, or +granting permissions. + + + +## Related pages + + + + + + + + + diff --git a/docs/en/concepts/certificates.mdx b/docs/en/concepts/certificates.mdx new file mode 100644 index 0000000..02fb148 --- /dev/null +++ b/docs/en/concepts/certificates.mdx @@ -0,0 +1,116 @@ +--- +title: Certificates +description: Understand the certificate roles behind KSeF authentication, enrollment, offline identity, and public encryption. +--- + +import { Aside, LinkCard, Steps } from '@astrojs/starlight/components'; + +KSeF uses the word certificate in a few different places. They are related by +cryptography, but they do not play the same role in the SDK. + +| Certificate role | What owns it | SDK surface | Purpose | +| --- | --- | --- | --- | +| Authentication signing material | Your application, user, HSM, certificate store, or TEST helper. | `client.authentication.with_xades()` and `ksef2.xades` | Signs the `AuthTokenRequest` used to authenticate. | +| KSeF-issued certificate | KSeF issues it after an enrollment request. You store it with its private key. | `auth.certificates` | Gives the authenticating subject a KSeF certificate for authentication or offline use. | +| Public encryption certificate | KSeF publishes it. The SDK reads it. | `client.encryption` and high-level workflows | Encrypts token-auth payloads, session keys, and export keys for KSeF. | + +Keeping those roles separate prevents two common design mistakes: treating a +certificate as if it carried KSeF permissions, or treating certificate +enrollment as if KSeF should receive your private key. + + + +## Identity and permissions + +A KSeF certificate is an identity carrier for the authenticating subject. It is +usually tied to a NIP, PESEL, or certificate fingerprint. It is not assigned to +a taxpayer context by itself and it does not contain the permission set used by +KSeF operations. + +Permissions are evaluated separately. A certificate can prove who is +authenticating, while KSeF still checks whether that subject has permission to +work in the selected login context. + +## Certificate types + +KSeF-issued certificates have one certificate type: + +| SDK value | Meaning | +| --- | --- | +| `authentication` | Can be used as certificate material for KSeF authentication. | +| `offline` | Used to identify offline-mode invoice issuance; it is not a login credential. | + +One issued certificate does not combine both roles. Request the type that +matches the workflow you are building. + +## Enrollment model + +Certificate enrollment is an authenticated workflow, but it is not just another +admin form. KSeF derives the enrollment subject data from the certificate used +for XAdES authentication. The CSR must use those subject values; changing them +causes KSeF to reject the request. + +```python +limits = auth.certificates.get_limits() +print(limits.can_request, limits.enrollment_remaining) + +subject = auth.certificates.get_enrollment_data() +print(subject.common_name, subject.country_name) +``` + +The SDK exposes the lifecycle as high-level methods, but the private key and CSR +still come from your certificate tooling: + + + +1. Authenticate with XAdES certificate material. +2. Read certificate limits and enrollment subject data. +3. Generate a private key and CSR outside the SDK. +4. Submit the CSR through `auth.certificates.enroll()`. +5. Poll enrollment status until KSeF returns a certificate serial number. +6. Retrieve the issued certificate and store it with its private key. + + + + + +## Relationship to XAdES and encryption + +XAdES uses certificate material to sign authentication XML. Certificate +enrollment manages KSeF-issued certificates. Public encryption certificates are +different again: they are KSeF-owned public keys used by the SDK to encrypt +payload keys. + +If a problem mentions private keys, CSRs, issued certificate serial numbers, or +offline certificates, it belongs in certificate management. If it mentions +`public_key_id`, token encryption, session encryption, or export package +decryption, it belongs in the encryption model. + +## Related pages + + + + + diff --git a/docs/en/concepts/clients-and-lifecycle.mdx b/docs/en/concepts/clients-and-lifecycle.mdx new file mode 100644 index 0000000..7cf06ed --- /dev/null +++ b/docs/en/concepts/clients-and-lifecycle.mdx @@ -0,0 +1,145 @@ +--- +title: Clients and Lifecycle +description: Understand root clients, authenticated clients, session handles, and resource ownership in ksef2. +--- + +import { Aside, LinkCard } from '@astrojs/starlight/components'; + +The SDK has a small object hierarchy. Each object represents a different +lifecycle boundary, not just a different namespace. + +```text +Client or AsyncClient + -> public root branches + -> authentication + -> AuthenticatedClient for one KSeF context + -> sessions, invoices, tokens, permissions, certificates, limits + -> online session, batch session, export handle +``` + +The root client chooses the KSeF environment and owns transport resources. The +authenticated client carries access to one context. Session clients and export +handles are narrower workflow objects that should not be reused as general SDK +entry points. + +## The object model + +| Object | What it owns | What to reuse | +| --- | --- | --- | +| `Client` / `AsyncClient` | Environment, HTTP transport, retry settings, TLS/proxy configuration, and public certificate cache. | Reuse while several operations share the same environment and transport policy. | +| Authenticated client | Access and refresh tokens for one login context. | Reuse while several operations belong to the same taxpayer context and credential state. | +| Online session client | One remote online-session reference plus session encryption material. | Use for one sending workflow, then close the session. | +| Batch session client or batch state | One batch upload workflow and package encryption material. | Use until upload, close, status polling, and UPO retrieval are complete. | +| Export handle | Export reference number plus local AES material needed to decrypt package parts. | Keep only until the export package has been downloaded and persisted. | + +This is the main lifecycle rule: reuse root and authenticated clients when the +environment and context match; treat session and export objects as workflow +handles. + +## Root clients + +The root client is the boundary around SDK-managed HTTP resources. It exposes +public branches that do not need authentication, such as encryption certificate +lookup and PEPPOL provider lookup. It also exposes `authentication`, which is +how you enter an authenticated KSeF context. + +Create a root client for one environment: TEST, DEMO, or PRODUCTION. Do not +switch one client between environments. If an application talks to more than one +environment, give each environment its own root client and its own lifecycle. + +Closing the root client releases the underlying HTTP resources when the SDK owns +them. If you pass your own `httpx` client into the SDK, the application that +created that `httpx` client still owns closing it. + +## Authenticated clients + +Authentication turns a root client into an authenticated client for one KSeF +context. That context is usually a taxpayer NIP plus a credential that proves +permission to operate there. + +The authenticated client is the entry point for protected branches: + +| Branch | Kind of workflow | +| --- | --- | +| `online_session` | Interactive invoice sending. | +| `batch` | Batch invoice package preparation, upload, status, and UPO retrieval. | +| `invoices` | Metadata queries, direct downloads, exports, and export package downloads. | +| `tokens` | KSeF token generation, listing, status, and revocation. | +| `permissions` | Permission grants, queries, revokes, and operation status. | +| `certificates` | KSeF-issued certificate enrollment, retrieval, limits, and revocation. | +| `limits` | Context, subject, and API rate-limit reads or overrides. | +| `sessions` / `invoice_sessions` | Authentication-session and invoice-session inspection. | + +An authenticated client can be reused across workflow functions when they belong +to the same KSeF context. Passing that authenticated client into workflow code +keeps transport setup and credential setup away from the business operation. + +## Session handles + +Online and batch session clients are not general authenticated clients. They are +handles for one remote KSeF session reference and the local encryption material +used by that session. + +An online session should be closed when no more invoices will be sent. Closing +the session tells KSeF the sending phase is done and allows the session to move +toward final status and UPO availability. + +A batch workflow also has a close boundary. KSeF starts processing only after +the encrypted package parts have been uploaded and the batch session is closed. + + + +## Sync and async + +`Client` and `AsyncClient` expose the same conceptual branches. The async client +changes the calling style: network calls are awaited and lifecycle blocks use +async context managers. It does not change the KSeF workflow model. + +Choose the sync client for scripts, CLI tools, synchronous workers, and +applications that do not already own an event loop. Choose the async client when +your application is already async, such as an async web service or async worker. + +## What to persist + +Do not persist root clients, authenticated clients, or session client objects. +Persist identifiers and artifacts: + +- environment and profile name; +- login context identifier; +- session reference number; +- invoice reference number; +- KSeF number; +- export reference and protected export handle material while the export is + still being downloaded; +- UPO XML bytes or storage location; +- last observed status payload for support and audit. + +Client objects are process resources. References and artifacts are application +state. + +## Related pages + + + + + diff --git a/docs/en/concepts/encryption.mdx b/docs/en/concepts/encryption.mdx new file mode 100644 index 0000000..2aa2f60 --- /dev/null +++ b/docs/en/concepts/encryption.mdx @@ -0,0 +1,96 @@ +--- +title: Encryption +description: Understand how ksef2 uses KSeF public certificates for token, session, and export encryption. +--- + +import { Aside, LinkCard } from '@astrojs/starlight/components'; + +KSeF expects clients to encrypt some payload material before it reaches the +API. The SDK hides that machinery in normal workflows, but it is useful to know +which key is being protected and what must be kept to continue the workflow. + +The public certificates used for that encryption are published by KSeF. Your +application fetches public certificate material, encrypts local secrets for +KSeF, and sends the `public_key_id` that identifies which KSeF public key was +used. + +## Where encryption appears + +| Workflow | Local secret | What KSeF receives | +| --- | --- | --- | +| Token authentication | The KSeF token plus challenge timestamp. | Encrypted token-auth payload and the matching `public_key_id`. | +| Online session | AES key and IV generated for invoice XML. | Encrypted session key, IV, and encrypted invoice payloads. | +| Batch session | AES key and IV for the batch package. | Encrypted batch key material and upload declarations. | +| Invoice export | AES key and IV for the export package. | Encrypted export key material; the SDK later uses local material to decrypt downloaded parts. | + + + +## Public certificate model + +KSeF publishes public key certificates with metadata that tells clients what the +key is for and when it is valid. + +| SDK field | Meaning | +| --- | --- | +| `certificate` | Base64 certificate material used by the SDK cryptography layer. | +| `certificate_id` | Identifier for the certificate object. | +| `public_key_id` | Identifier for the public key; sent back to KSeF after encryption. | +| `valid_from` / `valid_to` | Validity window used for cache and refresh decisions. | +| `usage` | `ksef_token_encryption` or `symmetric_key_encryption`. | + +```python +certificates = client.encryption.get_certificates( + usage=["symmetric_key_encryption"], +) + +for certificate in certificates: + print(certificate.public_key_id, certificate.valid_to, certificate.usage) +``` + +## Rotation and caching + +Treat public encryption certificates as rotating infrastructure. KSeF may +publish a new certificate for the same key or rotate to a new key. A new +certificate can change `certificate_id`; a new key changes `public_key_id`. + +Cache public certificates if it helps startup latency, but refresh them before +long-running workers open sessions, schedule exports, or start token +authentication. Do not hard-code certificate contents in application code. + +## What to persist + +For ordinary invoice sending, persist business identifiers, KSeF references, +UPO data, and final artifacts. Do not persist low-level encryption internals +unless another process must resume a session. + +For exports, keep the SDK `ExportHandle` until the package is downloaded. It +contains local decryption material needed for the export parts. For resumed +online or batch sessions, persist the serialized session state returned by the +SDK rather than trying to reconstruct keys by hand. + +## Related pages + + + + + diff --git a/docs/en/concepts/environments.mdx b/docs/en/concepts/environments.mdx new file mode 100644 index 0000000..5f21266 --- /dev/null +++ b/docs/en/concepts/environments.mdx @@ -0,0 +1,94 @@ +--- +title: Environments +description: Understand TEST, DEMO, and PRODUCTION environment choices in ksef2. +--- + +import { Aside, LinkCard } from '@astrojs/starlight/components'; + +KSeF workflows run against an explicit environment. Choose it when constructing +the root client and keep credentials, profiles, persisted session data, and +download URLs scoped to that environment. + +| Environment | Official role | Base URL | Typical use | +| --- | --- | --- | --- | +| `Environment.TEST` | Test / release-candidate environment | `https://api-test.ksef.mf.gov.pl/v2` | Local development, CI, SDK-generated TEST certificates, TEST data setup. | +| `Environment.DEMO` | Pre-production / demo environment | `https://api-demo.ksef.mf.gov.pl/v2` | Integration checks against production-like behavior with non-production credentials. | +| `Environment.PRODUCTION` | Production environment | `https://api.ksef.mf.gov.pl/v2` | Real taxpayer workflows and production credentials. | + + + +## Official environment boundaries + +TEST is intentionally looser than DEMO and PRODUCTION. It supports test data +workflows, SDK-generated test certificate material, and self-signed certificate +scenarios. TEST data is not a private production archive, so do not use real +invoice data there. + +DEMO is closer to production behavior, but it is still not a production invoice +environment. PRODUCTION is the only environment for real taxpayer workflows. +URLs returned by KSeF, such as package download links, belong to the same +environment host that produced them. + +Invoice schema availability also differs by environment: + +| Environment | Structured invoice formats | +| --- | --- | +| TEST | `FA(2)`, `FA(3)`, `FA_PEF(3)`, `FA_KOR_PEF(3)` | +| DEMO | `FA(3)`, `FA_PEF(3)`, `FA_KOR_PEF(3)` | +| PRODUCTION | `FA(3)`, `FA_PEF(3)`, `FA_KOR_PEF(3)` | + +## What the environment controls + +The environment selects the KSeF API base URL and the operational boundary for +credentials, profiles, certificates, test data, session references, invoice +numbers, and exports. + +```python +from ksef2 import Client, Environment + +client = Client(Environment.TEST) +``` + +`client.testdata` is available only in `Environment.TEST`. The SDK-generated +TEST certificate authentication helper is also TEST-only. + +## Safety boundaries + +Credentials are environment-specific. A profile created for PRODUCTION should +not be selected by a TEST client, and `with_profile()` rejects that mismatch. + +Persisted state should be tagged with the environment that produced it: + +- authentication profile name; +- taxpayer or context identifier; +- online or batch session reference; +- invoice reference number; +- KSeF number; +- export reference number. + +## Related pages + + + + + diff --git a/docs/en/concepts/glossary.mdx b/docs/en/concepts/glossary.mdx new file mode 100644 index 0000000..1f66665 --- /dev/null +++ b/docs/en/concepts/glossary.mdx @@ -0,0 +1,127 @@ +--- +title: Glossary +description: Short definitions for KSeF, SDK, invoice, signing, and workflow terms used in the ksef2 documentation. +--- + +import { Aside } from '@astrojs/starlight/components'; + + + +## Access token + +Bearer token redeemed during authentication and used by the authenticated client +for KSeF API calls. + +## Authenticated client + +SDK client branch returned by authentication. It is bound to one environment, +one KSeF context, and one access/refresh token pair. + +## Authenticating subject + +The person, entity, certificate fingerprint, or token material that proves +identity when authenticating to a KSeF context. + +## Batch session + +KSeF session used to upload prepared encrypted invoice package parts and then +start processing by closing the session. + +## Context + +The taxpayer or entity being represented during authentication, usually selected +with NIP or another supported context identifier. + +## DEMO + +Non-production KSeF environment used for integration checks with non-production +credentials. + +## Environment + +SDK value that selects the KSeF API base URL and operational boundary: +`Environment.TEST`, `Environment.DEMO`, or `Environment.PRODUCTION`. + +## Export + +Asynchronous KSeF workflow that prepares invoice metadata or invoice content +matching filters as an encrypted downloadable package. + +## Export handle + +SDK object containing the export reference number and local decryption material +needed to fetch and decrypt an export package. + +## FA(3) + +Invoice form schema represented by `FormSchema.FA3` when opening online or +batch invoice sessions. + +## KSeF number + +Identifier assigned by KSeF after successful invoice processing. Store it for +downloads, reconciliation, support, and UPO workflows. + +## HWM + +High Water Mark. KSeF's marker that invoice data is complete up to a +`permanent_storage_date`, used for reliable incremental synchronization. + +## Metadata + +Searchable invoice information returned by query APIs. Metadata is not the XML +invoice document. + +## NIP + +Polish tax identification number commonly used as a taxpayer or context +identifier. + +## Online session + +KSeF session used to send individual encrypted invoices and inspect their +session-bound status and UPO. + +## PEPPOL + +Public provider lookup area exposed by the root client. It is separate from +authenticated KSeF invoice processing state. + +## Profile + +Named local credential configuration compatible with `ksef2-cli` and SDK +`with_profile()` authentication. + +## Reference number + +KSeF handle for asynchronous or stateful work, such as authentication, sessions, +invoice submissions, exports, token generation, permission operations, and +certificate enrollment. + +## `permanent_storage_date` + +Metadata date used by KSeF's durable storage and High Water Mark synchronization +model. + +## Root client + +Unauthenticated `Client` or `AsyncClient` that owns environment, HTTP transport, +public lookup branches, and the authentication entry point. + +## TEST + +Sandbox KSeF environment used for local development, CI, TEST data setup, and +SDK-generated TEST certificates. + +## UPO + +Official confirmation available after the relevant session or invoice workflow +reaches the required processed state. + +## XAdES + +XML digital signature format used by certificate-based authentication flows. diff --git a/docs/en/concepts/invoice-lifecycle.mdx b/docs/en/concepts/invoice-lifecycle.mdx new file mode 100644 index 0000000..4da49da --- /dev/null +++ b/docs/en/concepts/invoice-lifecycle.mdx @@ -0,0 +1,157 @@ +--- +title: Invoice lifecycle +description: Understand how an invoice moves from XML submission to KSeF number, metadata, download, and UPO. +--- + +import { Aside, LinkCard, Steps } from '@astrojs/starlight/components'; + +In KSeF, sending an invoice is the start of processing, not the final outcome. +The API first accepts encrypted invoice data into a session. KSeF then validates +and processes that invoice asynchronously. + +The SDK reflects that model: sending returns a processing handle first. A KSeF +number, metadata visibility, invoice download, and UPO come later. + +```text +FA(3) XML + -> online or batch session + -> invoice reference number + -> KSeF processing + -> KSeF number + -> metadata, download, UPO + -> failed invoice status +``` + +## The stages + + + +1. Prepare valid invoice XML. + + The SDK can send bytes from a file or bytes generated by the FA(3) builder. + +2. Submit the XML through an online or batch session. + + Online sessions send one invoice at a time. Batch sessions upload a prepared + ZIP package and expose per-invoice results after package processing. + +3. Store the returned references. + + The invoice reference number is the first durable handle for a submitted + invoice. The session reference tells KSeF where that invoice was submitted. + +4. Poll the session or invoice status. + + A successful HTTP response does not mean the invoice has a KSeF number yet. + +5. Use the final identifiers for retrieval and audit. + + After successful processing, the KSeF number becomes the stable identifier for + direct download, reconciliation, metadata lookup, and many support workflows. + + + +## Send is not acceptance + +`send_invoice()` encrypts the XML and submits it into an open online session. It +returns an invoice `reference_number`. + +`send_invoice_and_wait()` does the same submission, then polls the invoice status +until KSeF assigns a `ksef_number`, reaches a failed terminal status, or the +local timeout expires. + +```python +sent = session.send_invoice(invoice_xml=xml_bytes) +print(sent.reference_number) + +status = session.get_invoice_status( + invoice_reference_number=sent.reference_number, +) +print(status.status.code, status.ksef_number) +``` + + + +## Identifiers to keep straight + +| Identifier | Where it appears | What it is for | +| --- | --- | --- | +| Local invoice number | Inside the invoice XML and local system. | Business correlation before and after KSeF processing. | +| Session reference number | Opening an online or batch session. | Finding the remote sending workflow. | +| Invoice reference number | Response from sending, and session invoice status. | Polling one invoice and downloading invoice UPO by reference. | +| Invoice hash | Session invoice status and batch preparation. | Correlating batch results with local source documents. | +| KSeF number | Successful invoice status and metadata. | Stable KSeF identifier for download, reconciliation, and support. | +| UPO reference | Session status when collective UPO is available. | Downloading a UPO page for the session or batch. | + +For batch sending, the invoice reference number may not be visible until KSeF +processes the uploaded package. Keep the prepared batch metadata so the +`invoice_hash` and `invoice_file_name` can connect KSeF results back to local +files. + +## Metadata and download are later surfaces + +Session status tells you what happened to a submission. Metadata and invoice +download are retrieval surfaces used after KSeF has processed and materialized +the invoice. + +Use the surfaces for different jobs: + +| Surface | Best question | +| --- | --- | +| Session invoice status | Did this submitted invoice get a KSeF number or fail? | +| Metadata query | Which processed invoices match these filters, roles, and dates? | +| Direct invoice download | Give me the XML for this known KSeF number. | +| Export package | Give me a larger filtered set of invoice XML files. | +| UPO | Give me official confirmation for the accepted invoice or session. | + +```python +invoice_xml = auth.invoices.download_invoice(ksef_number=status.ksef_number) +print(len(invoice_xml)) +``` + +## Dates are not interchangeable + +KSeF metadata exposes several dates. Choose the date based on the workflow, not +on the label that looks convenient. + +| Date | Meaning | +| --- | --- | +| `issue_date` | Date declared on the invoice document. | +| `invoicing_date` | Date KSeF received the invoice for processing. | +| `acquisition_date` | Date the invoice became available to a party in KSeF. | +| `permanent_storage_date` | Date used by durable storage and incremental export workflows. | + +For incremental synchronization, prefer the Permanent Storage model described in +the querying/export pages. It is designed to avoid missing invoices while KSeF +processing is asynchronous. + +## Related pages + + + + + + diff --git a/docs/en/concepts/limits.mdx b/docs/en/concepts/limits.mdx new file mode 100644 index 0000000..c822610 --- /dev/null +++ b/docs/en/concepts/limits.mdx @@ -0,0 +1,94 @@ +--- +title: Limits, Polling, and Retries +description: Understand KSeF context limits, subject limits, API rate limits, polling deadlines, and retry boundaries. +--- + +import { Aside, LinkCard } from '@astrojs/starlight/components'; + +KSeF limits are part of integration design. They influence how many invoices you +put into a session, how often workers poll, when to choose exports instead of +direct downloads, and how aggressively a system should retry after throttling. + +The SDK exposes limit reads and administrative overrides through +`auth.limits`, but most applications should treat limits as scheduling inputs, +not as values to change during ordinary business workflows. + +## Limit families + +| Limit family | SDK model | What it affects | +| --- | --- | --- | +| Context limits | `ContextLimits` | Online and batch session invoice counts and payload sizes. | +| Subject limits | `SubjectLimits` | Certificate enrollment and certificate issuance limits for the authenticated subject. | +| API rate limits | `ApiRateLimits` | Per-second, per-minute, and per-hour request volume for endpoint families. | + +```python +context = auth.limits.get_context_limits() +print(context.online_session.max_invoices) +print(context.batch_session.max_invoice_size_mb) + +rate = auth.limits.get_api_rate_limits() +print(rate.invoice_send.per_minute) +``` + +## How limits shape workflows + +Use online sending for low-volume or immediate invoice submission. Use batch +sending when invoices can be prepared as a package. Use exports when you need a +larger retrieval set; repeated direct downloads and high-frequency incremental +queries are usually the wrong architecture for a production synchronizer. + +For production applications, treat KSeF as a remote system of record that you +synchronize from at controlled intervals. Store metadata and downloaded invoice +content locally, then serve user-facing screens from your local store. + + + +## Polling deadlines + +Many KSeF workflows are asynchronous. The SDK provides helpers such as +`send_invoice_and_wait()`, `wait_for_invoice_ready()`, +`wait_for_export_package()`, `wait_for_invoice_download()`, and token activation +polling. + +Those helper timeouts are workflow wait limits. They are not the same as HTTP +socket timeouts. A polling timeout means the expected KSeF state was not reached +before the local deadline; it does not always mean the remote operation failed. + +## Retry boundaries + +The root client transport can retry transport failures and retryable KSeF +responses. That does not mean every operation is safe to repeat blindly. +Invoice submission, permission grants, certificate enrollment, token +generation, and session opening should be retried from your application boundary +with stored references and workflow-specific idempotency rules. + +When KSeF throttles a request, respect the returned retry guidance and back off +the worker schedule. If a workflow already has a KSeF reference number, prefer +checking status or resuming from that reference over recreating the operation. + +## Related pages + + + + + diff --git a/docs/en/concepts/overview.mdx b/docs/en/concepts/overview.mdx new file mode 100644 index 0000000..16bca01 --- /dev/null +++ b/docs/en/concepts/overview.mdx @@ -0,0 +1,142 @@ +--- +title: SDK Overview +description: Understand the main objects, workflow boundaries, and nouns used by ksef2. +--- + +import { Aside, CardGrid, LinkCard } from '@astrojs/starlight/components'; + +ksef2 is organized around a few long-lived entry points and short-lived KSeF +workflows. The root `Client` or `AsyncClient` chooses the environment and owns +transport configuration. Authentication turns that root client into an +authenticated client for one taxpayer context. Sessions are then opened only for +workflows that KSeF models as sessions. + +Use this section when you need to understand the shape of a KSeF workflow before +writing code. Use the how-to guides when you already know the task and want the +SDK calls. + + + + + + + + + + +## How the SDK maps the official API + +The official KSeF API documentation groups work into access, interactive +sending, batch sending, status and UPO, invoice retrieval, tokens, permissions, +certificates, limits, public encryption certificates, and PEPPOL. The SDK keeps +the same conceptual boundaries and exposes them as branches on the root or +authenticated client. + +| Official API area | SDK view | +| --- | --- | +| Access | `client.authentication` creates an authenticated client for one context. | +| Interactive and batch sending | `auth.online_session()` and `auth.batch_session()` own sending-session state. | +| Status and UPO | Session clients and `auth.invoice_sessions` inspect remote processing and confirmations. | +| Invoice retrieval | `auth.invoices` owns metadata queries, exports, direct downloads, and package download. | +| Tokens, permissions, certificates, limits | Administrative branches on the authenticated client. | +| Public data | Root-client branches such as `client.encryption`, `client.peppol`, and TEST data. | + +The low-level `raw` branches exist for custom protocol work, diagnostics, and +exact KSeF payload debugging. They are public, but they are not the normal +starting point for application code. + + + +## Main nouns + +| Noun | What it means in the SDK | +| --- | --- | +| Root client | The unauthenticated client that owns environment and HTTP transport. | +| Authenticated client | A client branch bound to redeemed KSeF access and refresh tokens for one context. | +| Online session | A short-lived sending context for individual encrypted invoice submissions. | +| Batch session | A short-lived sending context for prepared encrypted batch packages. | +| Invoice service | The authenticated branch for metadata queries, exports, direct downloads, and export package download. | +| Reference number | A handle returned by KSeF for sessions, invoices, exports, tokens, permissions, and other asynchronous work. | +| KSeF number | The identifier assigned after KSeF accepts and processes an invoice. | +| UPO | Official confirmation available after a session or invoice reaches the required processed state. | + +## What belongs where + +Use the root client for public lookup and setup: + +- `client.authentication` to start authentication. +- `client.encryption` to inspect public encryption certificates. +- `client.peppol` to query public PEPPOL provider data. +- `client.testdata` in `Environment.TEST` only. + +Use the authenticated client for context-specific work: + +- `auth.online_session()` and `auth.batch_session()` for sending. +- `auth.invoices` for metadata, exports, and downloads. +- `auth.tokens`, `auth.permissions`, `auth.certificates`, and `auth.limits` for administrative workflows. +- `auth.sessions` and `auth.invoice_sessions` for session management and history. + +## Related pages + + + + + + + + + + diff --git a/docs/en/concepts/peppol.mdx b/docs/en/concepts/peppol.mdx new file mode 100644 index 0000000..5467e6f --- /dev/null +++ b/docs/en/concepts/peppol.mdx @@ -0,0 +1,68 @@ +--- +title: PEPPOL +description: Understand PEPPOL provider lookup and where it fits in the SDK. +--- + +import { Aside, LinkCard } from '@astrojs/starlight/components'; + +PEPPOL provider lookup is a public root-client workflow. It does not require an +authenticated KSeF context and it does not participate in invoice session +processing by itself. + +Use it as reference data: populate provider choices, validate provider ids, or +refresh a local provider cache. Keep it separate from sending invoices, +querying KSeF invoice metadata, exports, and UPO status. + + + +## Where it sits in the SDK + +PEPPOL is exposed on the root client next to other public branches: + +```python +page = client.peppol.query() + +for provider in page.providers: + print(provider.id, provider.name) +``` + +The response is paginated. Use `client.peppol.all()` when you want the SDK to +iterate through pages: + +```python +providers_by_id = { + provider.id: provider.name + for provider in client.peppol.all() +} +``` + +## Product boundary + +Provider records are product reference data. Cache provider ids and names +according to your freshness requirements, and refresh that cache independently +from authenticated invoice workers. + +Only connect this lookup to invoice workflows when your own product has a +PEPPOL-specific step, such as letting a user choose a provider id before +building an integration-specific payload. + +## Related pages + + + + diff --git a/docs/en/concepts/permissions.mdx b/docs/en/concepts/permissions.mdx new file mode 100644 index 0000000..1d41ae7 --- /dev/null +++ b/docs/en/concepts/permissions.mdx @@ -0,0 +1,108 @@ +--- +title: Tokens and Permissions +description: Understand tokens, permission scopes, grants, revokes, and operation state in KSeF. +--- + +import { Aside, LinkCard } from '@astrojs/starlight/components'; + +Authentication proves identity. Permissions determine what that identity can do. +Tokens package selected permissions into reusable credential material. A +credential can authenticate successfully and still lack permission for the next +operation. + +Permissions determine what a subject, person, entity, EU entity, authorization, +or subunit can do in a KSeF context. Many permission changes are asynchronous +operations: the grant or revoke call returns an operation reference, then the +application checks the operation status. + +Keep permission code explicit about the target identifier, permission scope, and +context. This makes later audits and failure recovery much easier. + +## Tokens + +`auth.tokens` creates, lists, checks, and revokes KSeF tokens. Token generation +returns the token payload and a reference number. The SDK waits for token +activation before returning from `generate()`, but production systems should +still store the reference number and handle timeout or activation failure. + +A generated token has a fixed permission set. If the permissions should change, +generate a new token and revoke the old one after migration. Token generation is +available only in supported entity contexts, such as NIP or internal ID, and it +requires prior XAdES authentication before the reusable token credential exists. + +Tokens should be treated as secrets. Store the token value outside source +control and prefer environment variables or a secret manager. Profiles can refer +to token environment variables so CLI and Python code share configuration +without committing the secret. + + + +## Permissions + +Permission APIs are intentionally specific because KSeF distinguishes target +types and scopes. The SDK exposes separate methods for person, entity, +authorization, indirect, subunit, and EU entity grants, plus matching query and +revoke flows. + +The official permission model combines target identifiers, permission types, and +delegation rules: + +| Concept | Design implication | +| --- | --- | +| Target identifier | Use the correct identifier kind, such as NIP, PESEL, certificate fingerprint, EU VAT NIP, or internal ID. | +| Permission type | Keep finite choices explicit, such as invoice read, invoice write, credential read, credential manage, subunit manage, enforcement operations, or introspection. | +| Direct and indirect permissions | Query and revoke through the branch that matches how the permission was granted. | +| Delegation | `can_delegate` is meaningful only where KSeF permits delegation for that permission path. | +| Operation status | Grant and revoke calls return operation references; poll status before assuming the permission changed. | + +```python +from ksef2.models import EntityPermission + +operation = auth.permissions.grant_entity( + subject_value="1234567890", + permissions=[EntityPermission(type="invoice_read", can_delegate=False)], + description="Accounting office read access", + entity_name="Accounting Sp. z o.o.", +) + +status = auth.permissions.get_operation_status( + reference_number=operation.reference_number, +) +print(status.status.code, status.status.description) +``` + +When an authorization failure happens, check: + +- selected environment; +- authenticated taxpayer or context identifier; +- profile name and credential type; +- token permission set; +- target identifier and permission scope; +- whether the grant or revoke operation has completed. + +## Related pages + + + + + diff --git a/docs/en/concepts/querying-and-exports.mdx b/docs/en/concepts/querying-and-exports.mdx new file mode 100644 index 0000000..e1ddc00 --- /dev/null +++ b/docs/en/concepts/querying-and-exports.mdx @@ -0,0 +1,155 @@ +--- +title: Querying and Exports +description: Understand invoice metadata, direct downloads, export handles, package downloads, and incremental sync. +--- + +import { Aside, LinkCard } from '@astrojs/starlight/components'; + +Invoice retrieval has three different jobs: + +- metadata queries answer "which invoices exist for this subject and filter?"; +- direct downloads answer "give me the processed XML for this KSeF number"; +- exports answer "prepare a downloadable package for this larger set of invoices". + +KSeF is best treated as the exchange system and source of official invoice +records, not as the database behind every screen in your application. For most +integrations, the stable pattern is to query or export from KSeF, persist the +result locally, and serve operational views from your own store. + +## Metadata finds invoices + +`auth.invoices.query_metadata()` returns `QueryInvoicesMetadataResponse`. The +response contains `InvoiceMetadata` rows, not invoice XML. + +Metadata is useful for lists, reconciliation, polling after submission, and +deciding what to download later. A metadata row can include identifiers, dates, +party data, amounts, schema fields, hashes, attachment flags, and invoice mode. + +The filter describes the view KSeF should search: + +- `role`, such as `seller` or `buyer`; +- `date_type`, `date_from`, and `date_to`; +- optional `amount_min` and `amount_max`, with `amount_type` when an amount range is used; +- identifiers such as `seller_nip`, `buyer_nip`, `invoice_number`, or `ksef_number`; +- optional invoice data such as `invoice_types`, `invoice_schema`, `has_attachment`, and `invoicing_mode`. + + + +## Direct download gets one XML document + +`auth.invoices.download_invoice()` downloads one processed invoice XML document +by `ksef_number`. + +This path is intentionally narrow. Use it when you already have the KSeF number +from a session result, metadata query, UPO workflow, or your own database. If +the invoice was just accepted and KSeF has not exposed the XML yet, +`auth.invoices.wait_for_invoice_download()` polls until the document is +downloadable or the local timeout expires. + +## Exports produce encrypted packages + +Exports are asynchronous. You schedule an export with the same `InvoicesFilter` +shape used for metadata queries, keep the returned `ExportHandle`, wait for an +`InvoicePackage`, then download and decrypt its package parts. + +```text +InvoicesFilter + -> auth.invoices.schedule_export() + -> ExportHandle(reference_number, aes_key, iv) + -> auth.invoices.wait_for_export_package() + -> InvoicePackage(parts) + -> auth.invoices.fetch_package() + -> auth.invoices.fetch_package_bytes() +``` + +`fetch_package()` writes decrypted ZIP parts to disk. `fetch_package_bytes()` +returns those decrypted ZIP parts in memory. Both methods use the AES material +stored in the `ExportHandle`. + + + +## HWM is the sync boundary + +For unattended synchronization, the important concept is HWM: High Water Mark. +When KSeF returns `permanent_storage_hwm_date`, it is telling you that +permanent-storage invoice data is complete up to that boundary. + +The reliable sync shape is: + +```text +last persisted permanent_storage_date + -> query/export with restrict_to_permanent_storage_hwm_date=True + -> persist metadata and invoice content locally + -> store permanent_storage_hwm_date as the next starting boundary +``` + +Use `date_type="permanent_storage"` when your goal is incremental sync. Use +`restrict_to_permanent_storage_hwm_date=True` so KSeF clips the result to the +safe completed boundary. + +```python +from ksef2.models import InvoicesFilter + +filters = InvoicesFilter.for_buyer( + date_type="permanent_storage", + date_from=last_synced_at, + restrict_to_permanent_storage_hwm_date=True, +) + +page = auth.invoices.query_metadata(filters=filters) + +# QueryInvoicesMetadataResponse +# { +# "has_more": false, +# "is_truncated": false, +# "permanent_storage_hwm_date": "2026-06-25T10:00:00Z", +# "invoices": [ +# { +# "ksef_number": "1234567890-20260625-...", +# "invoice_number": "FV/42/2026", +# "permanent_storage_date": "2026-06-25T09:58:21Z" +# } +# ] +# } +``` + +If a metadata page or export package is truncated, continue from the returned +last date, such as `last_permanent_storage_date`, rather than assuming your +requested end of the window was fully covered. Deduplicate persisted records by +`ksef_number` when windows overlap. + +Export packages can also include `_metadata.json`, which helps reconcile the +downloaded XML package contents with the metadata rows you store. + +## Related pages + + + + + + + + diff --git a/docs/en/concepts/sessions.mdx b/docs/en/concepts/sessions.mdx new file mode 100644 index 0000000..661c1fa --- /dev/null +++ b/docs/en/concepts/sessions.mdx @@ -0,0 +1,167 @@ +--- +title: Sessions +description: Understand online, batch, authentication, and historical session state in ksef2. +--- + +import { Aside, LinkCard, Steps } from '@astrojs/starlight/components'; + +A KSeF session is a remote workflow container. It groups several API calls under +one reference number so KSeF can process encrypted invoices, expose status, and +produce UPO documents after the work is finished. + +In the SDK, a session object is a handle to that remote workflow. It is not the +source of truth. The source of truth is the session reference and the status +that KSeF returns for that reference. + +```text +authenticated client + -> open online or batch session + -> session reference number + -> send/upload encrypted invoice data + -> status, invoice results, UPO +``` + +## Session families + +KSeF has several session-like surfaces. They answer different questions and +live on different SDK branches. + +| Session family | SDK branch | What it represents | +| --- | --- | --- | +| Online invoice session | `auth.online_session()` | A short-lived interactive workflow for sending one or a few invoices. | +| Batch invoice session | `auth.batch_session()` or `auth.batch` | A workflow for uploading an encrypted ZIP package split into parts. | +| Authentication session | `auth.sessions` | Active bearer-token sessions created by authentication. | +| Invoice session history | `auth.invoice_sessions` | Historical online and batch invoice sessions that can be queried after the original process exits. | + +The first two are invoice-sending workflows. Authentication sessions and invoice +session history are inspection/administration surfaces. + +## Online sessions + +An online session is the interactive sending path. It is opened for a form +schema such as `FormSchema.FA3`, then invoices are sent one at a time into that +session. + +Opening the session is lightweight. The SDK loads the public KSeF encryption +certificate, creates session encryption material, opens the remote session, and +returns an `OnlineSessionClient` bound to the returned `reference_number`. + +```python +from ksef2 import FormSchema + +with auth.online_session(form_code=FormSchema.FA3) as session: + state = session.resume_state() + print(state.reference_number, state.valid_until) +``` + +Use the online session client for calls that are scoped to invoices sent in that +session: sending, listing session invoices, checking one session invoice, and +fetching invoice UPO by invoice reference or KSeF number. + +The context manager calls `close()` when the block exits. Closing an online +session tells KSeF that no more invoices will be sent and allows collective UPO +generation for the session. + + + +## Batch sessions + +A batch session is the bulk sending path. The unit sent to KSeF is not one XML +file. It is a prepared ZIP package that contains invoice XML files, split into +parts and encrypted before upload. + +The high-level `auth.batch` service owns the normal workflow: + + + +1. Build a ZIP package from invoice XML files or in-memory invoice bytes. + +2. Split the package into parts before encryption. + +3. Encrypt each part and calculate metadata for the package and parts. + +4. Open a batch session and receive upload instructions. + +5. Upload all parts, close the session, then poll status. + + + +```python +prepared = auth.batch.prepare_batch_from_paths( + invoice_paths=["invoice-1.xml", "invoice-2.xml"], +) + +state = auth.batch.submit_prepared_batch(prepared_batch=prepared) +print(state.reference_number) +``` + +For batch workflows, keep the mapping between local source files and the +prepared invoice metadata. KSeF reports per-invoice batch results through +session invoice fields such as `invoice_hash`, `invoice_file_name`, +`reference_number`, and eventually `ksef_number`. + +## State, status, and history + +These three concepts are easy to mix up: + +| Concept | Meaning | SDK shape | +| --- | --- | --- | +| State | Local data needed to resume a session handle. It includes sensitive session encryption material and, for batch, upload URLs. | `OnlineSessionResumeState`, `BatchSessionResumeState` | +| Status | KSeF's current view of the remote workflow. This is what tells you whether processing succeeded, failed, or is still running. | `SessionStatusResponse`, `SessionInvoiceStatusResponse` | +| History | Queryable list of previous online or batch sessions. Use it when you no longer have the original local object. | `auth.invoice_sessions` | + +Session state is useful for resuming SDK operations, but it is sensitive. Do not +print it or store it in logs. Status and history responses are the safe objects +to persist for audit and support. + +```python +status = auth.batch.get_status(session=state.reference_number) +print( + status.status.code, + status.invoice_count, + status.successful_invoice_count, + status.failed_invoice_count, +) +``` + +## What to store + +At minimum, store: + +- session reference number; +- invoice reference number returned after sending; +- KSeF number after acceptance; +- invoice hash or file name for batch correlation; +- UPO reference or downloaded UPO bytes when available; +- local correlation id from your own system. + +The KSeF session can outlive the Python process that opened it. A durable record +of those identifiers is what makes retries, later UPO download, and support +investigation possible. + +## Related pages + + + + + diff --git a/docs/en/concepts/status-and-upo.mdx b/docs/en/concepts/status-and-upo.mdx new file mode 100644 index 0000000..7aa5fdd --- /dev/null +++ b/docs/en/concepts/status-and-upo.mdx @@ -0,0 +1,168 @@ +--- +title: Status and UPO +description: Understand status surfaces, polling, UPO documents, and local timeouts in ksef2 workflows. +--- + +import { Aside, LinkCard, Steps } from '@astrojs/starlight/components'; + +KSeF workflows are asynchronous. Many SDK calls start work and return a +reference number; a different call tells you what happened later. + +Do not reduce this to one `done` flag. Each status surface answers a different +question and has a different reference number. + +## Which status do you need? + +| Question | SDK surface | Typical handle | +| --- | --- | --- | +| Did authentication finish? | `client.authentication` | authentication operation reference | +| Is an online or batch session still processing? | session client or `auth.batch` | session reference number | +| Did one submitted invoice succeed or fail? | online/batch session invoice status | invoice reference number | +| Are processed invoices visible for search? | `auth.invoices` metadata queries | filters, pagination, KSeF number | +| Is an export package ready? | `auth.invoices` export status | export reference number | +| Is a token active or revoked? | `auth.tokens` | token operation/reference number | +| Did a permission operation finish? | `auth.permissions` | permission operation reference | + +Use the surface that matches the question. A session can be complete while one +invoice inside it failed. An export can still be preparing even though invoice +metadata is already visible. A token operation can finish independently from any +invoice-sending session. + +## Session status + +Session status is KSeF's summary of a remote online or batch workflow. After +processing, it can include counters and UPO page references. + +```python +status = session.get_status() +print( + status.status.code, + status.invoice_count, + status.successful_invoice_count, + status.failed_invoice_count, +) +``` + +The status code belongs to the session workflow, not necessarily to every +invoice inside it. A session summary can say that processing completed and still +report failed invoices in `failed_invoice_count`. Inspect session invoices when +you need per-invoice details. + +```python +failed = session.list_failed_invoices() +for invoice in failed.invoices: + print(invoice.reference_number, invoice.status.code, invoice.status.details) +``` + +## Invoice status + +Invoice status is scoped to one invoice in one session. For online sending, you +usually get the invoice reference number immediately from `send_invoice()`. For +batch sending, per-invoice status becomes visible after KSeF processes the +uploaded package. + +An accepted invoice status has a `ksef_number`. A failed invoice status carries +the KSeF status code, description, and optional details. + +```python +invoice_status = session.get_invoice_status( + invoice_reference_number=sent.reference_number, +) +print(invoice_status.ksef_number, invoice_status.status.description) +``` + +## UPO documents + +UPO is the official confirmation document. It is XML signed by the Ministry of +Finance. Treat the returned bytes as an audit artifact and store them durably. + +UPO is not available at send time: + + + +1. Send or upload invoice data. + +2. Wait until KSeF processes the invoice or session. + +3. Read the UPO reference or use the invoice reference/KSeF number. + +4. Download and store the UPO XML bytes. + + + +The SDK exposes the common UPO paths through session clients: + +```python +invoice_upo = session.get_invoice_upo_by_reference( + invoice_reference_number=sent.reference_number, +) + +batch_upo = auth.batch.get_upo( + session=batch_state.reference_number, + upo_reference_number="upo-reference-from-session-status", +) +``` + +Status responses can also expose UPO page data such as +`download_url_expiration_date`. Use references for SDK calls and store the +downloaded XML once you have it. + +## Polling and timeouts + +Wait helpers poll KSeF until one of three things happens: + +- the target state appears; +- KSeF reports a terminal failure that the SDK can classify; +- the local timeout expires. + +A local timeout does not prove the remote operation failed. It only means the +target state was not observed before your deadline. + + + +Choose polling settings based on the workflow: + +| Workflow | Typical wait helper | +| --- | --- | +| Online invoice acceptance | `session.wait_for_invoice_ready()` or `send_invoice_and_wait()` | +| Batch processing | `auth.batch.wait_for_completion()` | +| Export package readiness | `auth.invoices.wait_for_export_package()` | +| Token generation or inspection | `auth.tokens.generate()` or `auth.tokens.status()` | + +## What to persist + +Persist enough information to inspect status later without the original Python +object: + +- the session reference number; +- the invoice reference number; +- the KSeF number after acceptance; +- the UPO reference or UPO XML bytes; +- the export or operation reference number for non-session workflows; +- the last observed status payload for audit. + +## Related pages + + + + + diff --git a/docs/en/concepts/xades.mdx b/docs/en/concepts/xades.mdx new file mode 100644 index 0000000..4eb8ebf --- /dev/null +++ b/docs/en/concepts/xades.mdx @@ -0,0 +1,94 @@ +--- +title: XAdES +description: Understand the XML signature step used by certificate-based KSeF authentication. +--- + +import { Aside, LinkCard, Steps } from '@astrojs/starlight/components'; + +XAdES is the XML signature format used when KSeF authentication is backed by a +certificate. In the SDK, XAdES belongs to authentication: it signs the +`AuthTokenRequest` built from a KSeF challenge and a login context. + +It is not the invoice XML schema, not session encryption, and not the export +package format. + +## What XAdES proves + +During XAdES authentication, KSeF checks a signed XML document and the +certificate embedded in the signature. The login context is the NIP passed to +the SDK method. The authenticating subject is read from the signing certificate, +for example: + +- a qualified personal certificate containing PESEL or NIP; +- a qualified organization seal containing NIP; +- a KSeF-issued authentication certificate; +- a certificate recognized by fingerprint permissions; +- TEST-only self-signed certificate material. + +KSeF then checks whether that subject is allowed to operate in the requested +context. + +## SDK layers + +Most applications should use `with_xades()` or a profile that dispatches to it: + +```python +auth = client.authentication.with_xades( + nip="5261040828", + cert=cert, + private_key=private_key, +) +``` + +The helper handles the normal sequence: + + + +1. Request an authentication challenge. +2. Build `AuthTokenRequest` XML for the context. +3. Sign the XML with XAdES. +4. Submit the signed XML to KSeF. +5. Poll the authentication operation. +6. Redeem access and refresh tokens. + + + +Use `ksef2.xades` directly when you need to load certificate material yourself, +inspect signed XML, or test a low-level integration boundary. + + + +## TEST certificates + +The SDK can generate TEST-only self-signed certificate material with +`with_test_certificate()` or lower-level helpers in `ksef2.xades`. That is a +development shortcut for `Environment.TEST`; it is not valid for DEMO or +PRODUCTION. + +## Related pages + + + + + diff --git a/docs/en/contributing/sync-generation.md b/docs/en/contributing/sync-generation.md index c8d7608..0aded46 100644 --- a/docs/en/contributing/sync-generation.md +++ b/docs/en/contributing/sync-generation.md @@ -99,5 +99,5 @@ For sync generation specifically, that means: ## Reference -- [Client setup](../workflows/client-setup.mdx) -- [Low-level API](../raw/overview.md) +- [Client setup](../how-to-guides/client-setup.mdx) +- [Low-level API](../reference/low-level/overview.mdx) diff --git a/docs/en/getting-started/overview.mdx b/docs/en/getting-started/overview.mdx new file mode 100644 index 0000000..ce2130b --- /dev/null +++ b/docs/en/getting-started/overview.mdx @@ -0,0 +1,102 @@ +--- +title: Overview +description: Start here for the ksef2 Python SDK documentation. +--- + +import logoDark from '../assets/logo-dark.png'; +import logoLight from '../assets/logo-light.png'; + +import { LinkCard } from '@astrojs/starlight/components'; + +
+ + +
+ + + +**ksef2** is a fully typed Python SDK for Poland's KSeF v2 API. + +> **Unofficial SDK.** ksef2 is a community-maintained Python SDK. It is not +> published, endorsed, or supported by Poland's Ministry of Finance. Use the +> official KSeF documentation as the authority for API behavior. + + + + +This project aims to be a go-to solution for developers building custom integrations, automations and back-office tools around KSeF without hand-writing HTTP requests, polling loops, or encryption handling. + +The main premise of the SDK is to provide a high-level pythonic interface that allows developers to focus on their business logic rather than the intricacies of the KSeF API. + +While the SDK abstracts away many of the complexities of interacting with the KSeF API, we are aware that some developers may want to have more control over the requests and responses. Therefore, the SDK also provides low-level access to the API endpoints, allowing developers to customize their interactions with the API as needed. + +## Next steps + + + + + + + + diff --git a/docs/en/getting-started/quickstart.md b/docs/en/getting-started/quickstart.md deleted file mode 100644 index 2c922b6..0000000 --- a/docs/en/getting-started/quickstart.md +++ /dev/null @@ -1,119 +0,0 @@ ---- -title: Quickstart -description: Send and query invoices with sync or async ksef2 clients. ---- - -This page shows the shape of the SDK in the TEST environment. You create a -client, authenticate, send one FA(3) invoice, then query recent invoice -metadata. - -## Install - -```bash -pip install ksef2 -``` - -ksef2 requires Python 3.12 or newer. - -## Send an invoice - -Use `Client` in scripts and command-line tools. Use `AsyncClient` only when your -application already owns an event loop. - -```python -from datetime import datetime, timedelta, timezone -from pathlib import Path - -from ksef2 import Client, Environment, FormSchema -from ksef2.domain.models import InvoicesFilter - -NIP = "5261040828" - -client = Client(Environment.TEST) -auth = client.authentication.with_test_certificate(nip=NIP) - -with auth.online_session(form_code=FormSchema.FA3) as session: - status = session.send_invoice_and_wait( - invoice_xml=Path("invoice.xml").read_bytes(), - timeout=60.0, - ) - print(status.ksef_number) - -filters = InvoicesFilter( - role="seller", - date_type="issue_date", - date_from=datetime.now(tz=timezone.utc) - timedelta(days=1), - date_to=datetime.now(tz=timezone.utc), - amount_type="brutto", -) - -export = auth.invoices.schedule_export(filters=filters) -package = auth.invoices.wait_for_export_package( - reference_number=export.reference_number, - timeout=120.0, -) - -for path in auth.invoices.fetch_package( - package=package, - export=export, - target_directory="downloads", -): - print(path) -``` - -For repeated local or production work, create a CLI-compatible profile and use -the same profile from Python: - -```python -from ksef2 import Client, Environment - -client = Client(Environment.PRODUCTION) -auth = client.authentication.with_profile("prod-token") -``` - -## Async shape - -The async client uses the same branches and method names. Await network calls -and use async context managers for client/session lifecycles. - -```python -import asyncio -from pathlib import Path - -from ksef2 import AsyncClient, Environment, FormSchema - -NIP = "5261040828" - - -async def main() -> None: - async with AsyncClient(Environment.TEST) as client: - auth = await client.authentication.with_test_certificate(nip=NIP) - - async with auth.online_session(form_code=FormSchema.FA3) as session: - status = await session.send_invoice_and_wait( - invoice_xml=Path("invoice.xml").read_bytes(), - timeout=60.0, - ) - print(status.ksef_number) - - -asyncio.run(main()) -``` - -## What happened - -1. The root client selected the TEST environment. -2. `with_test_certificate()` created a TEST-only XAdES certificate and returned - an authenticated client. -3. `online_session()` opened an invoice session with encryption material. -4. `send_invoice_and_wait()` sent XML and waited until KSeF assigned a number. -5. `auth.invoices` handled metadata export and package download outside the - sending session. - -## Related pages - -- [Client setup](../workflows/client-setup.mdx) -- [Authentication workflow](../workflows/authentication.mdx) -- [Sending invoices](../workflows/sending-invoices.mdx) -- [Downloading invoices](../workflows/downloading-invoices.mdx) -- [API reference](../reference/api-signatures.md) diff --git a/docs/en/getting-started/quickstart.mdx b/docs/en/getting-started/quickstart.mdx new file mode 100644 index 0000000..7525c34 --- /dev/null +++ b/docs/en/getting-started/quickstart.mdx @@ -0,0 +1,214 @@ +--- +title: Quickstart +description: Generate, send, confirm, and download a TEST invoice with ksef2. +--- + +import { Aside, LinkCard, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; + +This quickstart follows one TEST invoice through the SDK: build an FA(3) +invoice, authenticate with a TEST certificate, send it in an online session, +download the UPO, and fetch the processed invoice XML back from KSeF. + +## Install + +Choose your preferred installation method: + + + +```bash +pip install ksef2 +``` + + + + +```bash +uv add ksef2 +``` + + + + +ksef2 requires Python 3.12 or newer. + +## Code example + +Copy the following into a `quickstart.py` file. + +```python title="quickstart.py" +from datetime import date, datetime, timezone +from decimal import Decimal +from pathlib import Path + +from ksef2 import Client, Environment, FormSchema +from ksef2.fa3 import FA3InvoiceBuilder, VatRate + + +SELLER_NIP = "5261040828" +DOWNLOADS = Path("downloads") + +invoice_number = f"QS/{datetime.now(timezone.utc):%Y%m%d%H%M%S}" +invoice_xml = ( + FA3InvoiceBuilder() + .header(system_info="ksef2 quickstart") + .seller( + name="Demo Seller Sp. z o.o.", + tax_id=SELLER_NIP, + country_code="PL", + address_line_1="Prosta 1", + address_line_2="00-001 Warszawa", + ) + .buyer( + name="Demo Buyer Sp. z o.o.", + country_code="PL", + address_line_1="Kwiatowa 2", + address_line_2="00-002 Warszawa", + ) + .standard() + .issue_date(date.today()) + .issue_place("Warszawa") + .invoice_number(invoice_number) + .rows() + .add_line( + name="Consulting service", + quantity=Decimal("1"), + unit_price_net=Decimal("100.00"), + vat_rate=VatRate.VAT_23, + ) + .done() + .done() + .to_xml() + .encode("utf-8") +) + +DOWNLOADS.mkdir(exist_ok=True) +(DOWNLOADS / "generated-invoice.xml").write_bytes(invoice_xml) + +with Client(Environment.TEST) as client: + auth = client.authentication.with_test_certificate(nip=SELLER_NIP) + + with auth.online_session(form_code=FormSchema.FA3) as session: + sent = session.send_invoice(invoice_xml=invoice_xml) + print("Sent invoice:") + print(sent.model_dump_json(indent=2)) + + status = session.wait_for_invoice_ready( + invoice_reference_number=sent.reference_number, + timeout=120.0, + ) + print("Invoice status:") + print(status.model_dump_json(indent=2)) + + upo_xml = session.get_invoice_upo_by_reference( + invoice_reference_number=sent.reference_number, + ) + (DOWNLOADS / "upo.xml").write_bytes(upo_xml) + print("Saved downloads/upo.xml") + + if status.ksef_number is None: + raise RuntimeError("KSeF did not assign an invoice number.") + + downloaded_xml = auth.invoices.wait_for_invoice_download( + ksef_number=status.ksef_number, + timeout=120.0, + ) + (DOWNLOADS / "processed-invoice.xml").write_bytes(downloaded_xml) + print("Saved downloads/processed-invoice.xml") +``` + +Run the Python script: + +```bash +python quickstart.py +# or +uv run quickstart.py +``` + +The script writes three local files: +- `downloads/generated-invoice.xml` +- `downloads/upo.xml` +- `downloads/processed-invoice.xml` + +After the script completes, inspect the generated XML, the UPO, and the XML +returned by KSeF. The generated and processed invoice XML should represent the +same invoice; keep the processed XML as the KSeF-returned copy. + + + +## What happened + + + +1. `FA3InvoiceBuilder` created a minimal FA(3) invoice XML using public SDK + field names such as `invoice_number`, `tax_id`, and `unit_price_net`. + +2. `Client(Environment.TEST)` selected the TEST KSeF base URLs and owned the + SDK HTTP resources. + +3. `with_test_certificate()` authenticated the TEST context represented by + `SELLER_NIP`. + +4. `online_session(FormSchema.FA3)` opened an online session for FA(3) invoice + submission. + +5. `send_invoice()` submitted the invoice and returned a session invoice + reference number. + +6. `wait_for_invoice_ready()` polled until KSeF assigned a KSeF invoice number. + +7. `get_invoice_upo_by_reference()` downloaded the UPO XML, and + `auth.invoices.wait_for_invoice_download()` fetched the processed invoice XML. + + + +## Adapt the script + +Change the invoice fields or add more lines when you adapt the script. To move +the same shape into DEMO or PRODUCTION, change the root `Environment` and use a +credential that belongs to that environment. + +For repeated local work, create a CLI-compatible profile and replace the +authentication line with `client.authentication.with_profile("test-company")`. +Keep the profile environment aligned with the root client environment. + +## Related pages + + + + + + + + diff --git a/docs/en/guides/admin.md b/docs/en/guides/admin.md deleted file mode 100644 index 25f33e3..0000000 --- a/docs/en/guides/admin.md +++ /dev/null @@ -1,88 +0,0 @@ ---- -title: Admin Workflows -description: Manage KSeF tokens, permissions, certificates, limits, TEST data, and lookup helpers. ---- - -Most administrative KSeF workflows live on authenticated client branches. Keep -the handwritten docs focused on where each workflow starts; use the API -reference for every method and model field. - -## Tokens - -```python -token = auth.tokens.generate( - permissions=["invoice_read"], - description="nightly export", -) -print(token.token) - -for page in auth.tokens.list_all(): - for item in page.tokens: - print(item.reference_number, item.status) - -auth.tokens.revoke(reference_number="token-reference") -``` - -## Permissions - -```python -operation = auth.permissions.grant_person( - subject_type="pesel", - subject_value="90010112345", - permissions=["invoice_read"], - description="Read invoices", - first_name="Jan", - last_name="Kowalski", -) - -status = auth.permissions.get_operation_status( - reference_number=operation.reference_number, -) -print(status.status) - -attachment_status = auth.permissions.get_attachment_permission_status() -print(attachment_status.can_use_attachments) -``` - -## Certificates - -```python -limits = auth.certificates.get_limits() -print(limits.can_request) - -for certificate in auth.certificates.all(): - print(certificate.serial_number, certificate.status) -``` - -## Limits and TEST data - -```python -context_limits = auth.limits.get_context_limits() -print(context_limits.online_session.max_invoices) - -client.testdata.create_subject( - nip="5261040828", - subject_type="vat_group", - description="Quickstart company", -) -``` - -The `testdata` branch is available only in `Environment.TEST`. - -## Public lookups - -Some branches do not require an authenticated client. - -```python -certificates = client.encryption.get_certificates() -providers = client.peppol.query() -``` - -## Reference - -- [Tokens API](../reference/api/tokens.md) -- [Permissions API](../reference/api/permission-grants.md) -- [Certificates API](../reference/api/certificates.md) -- [Limits API](../reference/api/limits.md) -- [TEST data API](../reference/api/testdata.md) -- [PEPPOL API](../reference/api/peppol.md) diff --git a/docs/en/guides/client.md b/docs/en/guides/client.md deleted file mode 100644 index 969f43d..0000000 --- a/docs/en/guides/client.md +++ /dev/null @@ -1,110 +0,0 @@ ---- -title: Client -description: Create a ksef2 client, authenticate, and choose sync or async usage. ---- - -The root client owns transport configuration and exposes the public SDK entry -points. Create one client for the target KSeF environment, authenticate, then -use the authenticated branches for invoices, sessions, tokens, permissions, and -certificates. - -## Create a client - -```python -from ksef2 import Client, Environment - -client = Client(Environment.TEST) -``` - -Use `Environment.DEMO` or `Environment.PRODUCTION` outside local TEST workflows. - -```python -from ksef2 import AsyncClient, Environment - -async with AsyncClient(Environment.TEST) as client: - ... -``` - -## Authenticate - -TEST can use an SDK-generated certificate. - -```python -auth = client.authentication.with_test_certificate(nip="5261040828") -``` - -DEMO and PRODUCTION require a real certificate or an existing KSeF token. - -```python -from ksef2.xades import load_certificate_from_pem, load_private_key_from_pem - -cert = load_certificate_from_pem("company.pem") -key = load_private_key_from_pem("company.key") - -auth = Client(Environment.DEMO).authentication.with_xades( - nip="5261040828", - cert=cert, - private_key=key, -) -``` - -```python -auth = client.authentication.with_token( - ksef_token="your-ksef-token", - nip="5261040828", -) -``` - -## Use authenticated branches - -```python -from ksef2 import FormSchema - -with auth.online_session(form_code=FormSchema.FA3) as session: - ... - -invoices = auth.invoices -tokens = auth.tokens -permissions = auth.permissions -certificates = auth.certificates -``` - -Async code uses the same names and awaits network calls. - -```python -async with AsyncClient(Environment.TEST) as client: - auth = await client.authentication.with_test_certificate(nip="5261040828") - async with auth.online_session(form_code=FormSchema.FA3) as session: - ... -``` - -## Handle errors - -Catch SDK-classified failures with `KSeFException`. Catch `httpx.HTTPError` -separately for transport failures before KSeF returns a response. - -```python -import httpx - -from ksef2 import KSeFException - -try: - auth = client.authentication.with_test_certificate(nip="5261040828") -except KSeFException as exc: - print(exc) -except httpx.HTTPError as exc: - print(f"Transport failed: {exc}") -``` - -Common specialized SDK exceptions include `KSeFAuthError`, -`KSeFRateLimitError`, `KSeFValidationError`, and `KSeFClientClosedError`. - -## Reference - -- [Authentication workflow](../workflows/authentication.mdx) -- [Public API contract](public-api.md) -- [Error handling](errors.md) -- [Low-level API](../raw/overview.md) -- [Access API](../reference/api/access.md) -- [Active sessions API](../reference/api/active-sessions.md) -- [Errors reference](../reference/api/errors.md) diff --git a/docs/en/guides/errors.md b/docs/en/guides/errors.md deleted file mode 100644 index 8d154f3..0000000 --- a/docs/en/guides/errors.md +++ /dev/null @@ -1,142 +0,0 @@ ---- -title: Error Handling -description: Catch SDK exceptions, inspect KSeF responses, and handle polling timeouts. ---- - -Use SDK exceptions for failures that KSeF returns or that the SDK can classify. -Use `httpx.HTTPError` for transport failures where no KSeF response was parsed. - -## Catch SDK and transport errors separately - -```python -import httpx - -from ksef2 import KSeFApiError, KSeFException - -try: - result = auth.invoices.query_metadata(filters=filters) -except KSeFApiError as exc: - print(exc.status_code) - print(exc.exception_code) -except KSeFException as exc: - print(exc.context) -except httpx.HTTPError as exc: - print(f"Network or transport failure: {exc}") -``` - -Catch specific SDK exceptions before `KSeFException`. `KSeFException` is the -base class for SDK-classified errors, including API responses, validation, -encryption, session lifecycle, and polling timeout failures. - -## Inspect API response details - -`KSeFApiError` is raised for KSeF 4xx and 5xx responses. It exposes: - -- `status_code`: the HTTP status code returned by KSeF; -- `exception_code`: the normalized `ExceptionCode` when KSeF returned a known - exception code; -- `response`: the parsed KSeF error model when the response body could be - parsed. - -```python -from ksef2 import ExceptionCode, KSeFApiError - -try: - xml = auth.invoices.download_invoice(ksef_number=ksef_number) -except KSeFApiError as exc: - if exc.exception_code is ExceptionCode.NOT_PROCESSED_YET: - print("KSeF knows the invoice, but it is not ready yet.") - if exc.response is not None: - print(exc.response.model_dump_json(indent=2)) -``` - -The parsed `response` model preserves the KSeF payload shape. Use -`model_dump()` or `model_dump_json()` when logging structured diagnostics. - -## Handle rate limits - -KSeF `429` responses raise `KSeFRateLimitError`. When KSeF sends a -`Retry-After` header, the SDK exposes it as `retry_after`. - -```python -from time import sleep - -from ksef2 import KSeFRateLimitError - -try: - page = auth.invoices.query_metadata(filters=filters) -except KSeFRateLimitError as exc: - delay = exc.retry_after if exc.retry_after is not None else 5 - sleep(delay) -``` - -For background workers, combine `retry_after` with your queue or retry policy -instead of sleeping inside request handlers. - -## Handle polling timeouts - -Operations that poll KSeF raise SDK timeout exceptions when the configured -`timeout` expires. These exceptions are not HTTP timeouts. They mean the SDK -kept polling successfully, but KSeF did not reach the expected state in time. - -| Operation | Timeout exception | -| --- | --- | -| Authentication polling | `KSeFAuthPollingTimeoutError` | -| Token activation polling | `KSeFTokenStatusTimeoutError` | -| Online invoice processing | `KSeFInvoiceProcessingTimeoutError` | -| Invoice metadata visibility | `KSeFInvoiceQueryTimeoutError` | -| Direct invoice download readiness | `KSeFInvoiceDownloadTimeoutError` | -| Export package readiness | `KSeFExportTimeoutError` | -| Batch session completion | `KSeFBatchSessionTimeoutError` | - -Most timeout exceptions expose the relevant reference number plus `timeout`. - -```python -from ksef2 import KSeFInvoiceProcessingTimeoutError - -try: - status = session.wait_for_invoice_ready( - invoice_reference_number=reference_number, - timeout=60.0, - ) -except KSeFInvoiceProcessingTimeoutError as exc: - print(exc.invoice_reference_number) - print(exc.timeout) -``` - -Persist session and invoice references before polling. A later worker can resume -status checks even if the first process times out. - -## Retry `NOT_PROCESSED_YET` - -Some lower-level KSeF calls can return `ExceptionCode.NOT_PROCESSED_YET` while a -resource exists but is not ready. High-level wait helpers already handle this -where it is part of the workflow, for example `wait_for_invoice_download()`. - -```python -xml = auth.invoices.wait_for_invoice_download( - ksef_number=ksef_number, - timeout=120.0, - poll_interval=2.0, -) -``` - -If you call lower-level methods directly, treat `NOT_PROCESSED_YET` as a -retryable state only for operations where KSeF documents asynchronous -availability. Do not retry validation or authorization failures as if they were -processing delays. - -## Recommended flow - -1. Catch the narrow SDK exception that your workflow can act on. -2. Use `KSeFApiError.response` for structured diagnostics. -3. Use `KSeFRateLimitError.retry_after` to schedule retries. -4. Treat SDK polling timeouts as resumable workflow state, not as lost work. -5. Catch `httpx.HTTPError` separately for network, TLS, DNS, and connection - failures. - -## Reference - -- [Client guide](client.md) -- [Status and UPO workflow](../workflows/status-upo.mdx) -- [Errors reference](../reference/api/errors.md) diff --git a/docs/en/guides/fa3-builder.md b/docs/en/guides/fa3-builder.md new file mode 100644 index 0000000..f69a3f4 --- /dev/null +++ b/docs/en/guides/fa3-builder.md @@ -0,0 +1,109 @@ +--- +title: FA(3) Builder +description: Build FA(3) invoice XML with typed SDK helpers. +--- + +Use `ksef2.fa3` when you want to build FA(3) invoice XML in Python instead of +hand-writing XML. + +```python +from datetime import date +from decimal import Decimal + +from ksef2.fa3 import FA3InvoiceBuilder, VatRate + +builder = ( + FA3InvoiceBuilder() + .header(system_info="my app") + .seller( + name="ACME S.A.", + tax_id="1234567890", + country_code="PL", + address_line_1="ul. Przykladowa 123", + ) + .buyer( + name="XYZ GmbH", + country_code="DE", + address_line_1="Unter den Linden 1", + ) + .standard() + .issue_place("Warszawa") + .issue_date(date(2026, 3, 29)) + .invoice_number("FV/2026/03/0001") + .rows() + .add_line( + name="Consulting service", + supply_date=date(2026, 3, 29), + unit_of_measure="h", + quantity=Decimal("10"), + unit_price_net=Decimal("100.00"), + vat_rate=VatRate.VAT_23, + ) + .done() + .done() +) + +xml_text = builder.to_xml() +``` + +The usual flow is: + +1. Create the root builder. +2. Fill in `header(...)`, `seller(...)`, and `buyer(...)`. +3. Choose the invoice kind. +4. Add nested sections such as `rows()`, `payment()`, or `annotations()`. +5. Finish with `build()`, `to_spec()`, or `to_xml()`. + +Each `.done()` returns to the previous builder level. + +## Invoice kinds + +- `standard()` +- `simplified()` +- `correction()` +- `advance()` +- `settlement()` +- `correction_advance()` +- `correction_settlement()` + +## Output forms + +```python +invoice = builder.build() # KsefInvoice +spec = builder.to_spec() # FA(3) Faktura model +xml_text = builder.to_xml() # XML string +``` + +## Save and load drafts + +Persist the builder state when a user can leave an invoice unfinished. + +```python +from ksef2.fa3 import FA3InvoiceBuilder, KsefInvoiceDraft + +draft = builder.dump_state() +json_text = builder.dump_state_json(indent=2) + +same_draft = KsefInvoiceDraft.model_validate_json(json_text) +restored = FA3InvoiceBuilder.from_state(same_draft) +restored_from_json = FA3InvoiceBuilder.from_state_json(json_text) +``` + +## Send the XML + +Send builder output through the same online-session flow as hand-written XML. + +```python +from ksef2 import FormSchema + +with auth.online_session(form_code=FormSchema.FA3) as session: + status = session.send_invoice_and_wait( + invoice_xml=builder.to_xml().encode("utf-8"), + ) + print(status.ksef_number) +``` + +## Reference + +- [FA(3) API reference](../reference/api/fa3.md) +- [Invoices](invoices.md) diff --git a/docs/en/guides/invoices.md b/docs/en/guides/invoices.md deleted file mode 100644 index b67e258..0000000 --- a/docs/en/guides/invoices.md +++ /dev/null @@ -1,119 +0,0 @@ ---- -title: Invoices -description: Send, query, export, and download invoices with ksef2 clients. ---- - -Use online sessions to send invoice XML. Use `auth.invoices` for metadata -queries, exports, package download, and direct invoice download. - -Async applications use the same entry points on `AsyncClient`; await network -operations and use `async with auth.online_session(...)` for session lifecycle. - -## Send an invoice - -```python -from pathlib import Path - -from ksef2 import FormSchema - -with auth.online_session(form_code=FormSchema.FA3) as session: - status = session.send_invoice_and_wait( - invoice_xml=Path("invoice.xml").read_bytes(), - timeout=60.0, - ) - print(status.ksef_number) -``` - -If you need more control, call `send_invoice()` and poll the returned reference -number yourself. - -```python -result = session.send_invoice(invoice_xml=Path("invoice.xml").read_bytes()) -status = session.wait_for_invoice_ready( - invoice_reference_number=result.reference_number, -) -``` - -## Query metadata - -```python -from datetime import datetime, timedelta, timezone - -from ksef2.domain.models import InvoicesFilter, InvoiceMetadataParams - -filters = InvoicesFilter( - role="seller", - date_type="issue_date", - date_from=datetime.now(tz=timezone.utc) - timedelta(days=7), - date_to=datetime.now(tz=timezone.utc), - amount_type="brutto", - invoicing_mode="online", - invoice_types=["vat"], -) - -result = auth.invoices.query_metadata( - filters=filters, - params=InvoiceMetadataParams(sort_order="asc"), -) -print(len(result.invoices)) -``` - -Use iterator helpers when you want the SDK to follow pagination. - -```python -for page in auth.invoices.query_metadata_pages(filters=filters): - print(len(page.invoices), page.has_more) - -for invoice in auth.invoices.all_metadata(filters=filters): - print(invoice.ksef_number) -``` - -## Export and download - -```python -zip_parts = auth.invoices.export_and_download(filters=filters) -print(len(zip_parts)) -``` - -Use the lower-level calls when you want to persist package files as they arrive. - -```python -export = auth.invoices.schedule_export(filters=filters) -package = auth.invoices.wait_for_export_package( - reference_number=export.reference_number, -) - -for path in auth.invoices.fetch_package( - package=package, - export=export, - target_directory="downloads", -): - print(path) -``` - -## Download by KSeF number - -```python -xml_bytes = auth.invoices.download_invoice(ksef_number="KSeF-number") -print(len(xml_bytes)) -``` - -## Async shape - -```python -export = await auth.invoices.schedule_export(filters=filters) -package = await auth.invoices.wait_for_export_package( - reference_number=export.reference_number, -) -zip_parts = await auth.invoices.fetch_package_bytes(package=package, export=export) -print(len(zip_parts)) -``` - -## Reference - -- [Sending invoices](../workflows/sending-invoices.mdx) -- [Querying invoices](../workflows/querying-invoices.mdx) -- [Downloading invoices](../workflows/downloading-invoices.mdx) -- [Interactive sending API](../reference/api/interactive-sending.md) -- [Invoice retrieval API](../reference/api/invoice-retrieval.md) -- [Status and UPO API](../reference/api/status-upo.md) diff --git a/docs/en/workflows/authentication.mdx b/docs/en/how-to-guides/authenticate.mdx similarity index 80% rename from docs/en/workflows/authentication.mdx rename to docs/en/how-to-guides/authenticate.mdx index 3f5d2ad..ed4d422 100644 --- a/docs/en/workflows/authentication.mdx +++ b/docs/en/how-to-guides/authenticate.mdx @@ -1,9 +1,9 @@ --- -title: Authentication +title: Authenticate description: Configure ksef2 credentials with environment variables, KSeF tokens, XAdES certificates, or CLI-compatible profiles. --- -import { Aside, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; +import { Aside, LinkCard, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; Authentication turns a root `Client` into an authenticated client. Use the root client to choose the KSeF environment, then choose exactly one authentication @@ -43,12 +43,16 @@ keep the boundary explicit in your application: ```python import os -from ksef2 import Client, Environment +from ksef2 import Environment environment_name = os.environ.get("KSEF2_ENV", "test").upper() -client = Client(Environment[environment_name]) +environment = Environment[environment_name] ``` +The method snippets below assume `client` is an open root client for the target +environment. In scripts, prefer a context manager such as +`with Client(Environment.TEST) as client:`. + ## Choose an authentication method @@ -182,11 +186,11 @@ Use an existing profile from SDK code: ```python from ksef2 import Client, Environment -client = Client(Environment.PRODUCTION) -auth = client.authentication.with_profile() +with Client(Environment.PRODUCTION) as client: + auth = client.authentication.with_profile() -# Or select a named profile explicitly. -auth = client.authentication.with_profile("prod-token") + # To select a named profile explicitly instead: + # auth = client.authentication.with_profile("prod-token") ``` The root client environment must match the selected profile environment. @@ -194,7 +198,7 @@ The root client environment must match the selected profile environment. Create or update the same profile file from SDK code: ```python -from ksef2 import Client, Environment +from ksef2 import Environment from ksef2.profiles import Profile, ProfileStore, TokenProfileAuth store = ProfileStore.default() @@ -219,6 +223,17 @@ async with AsyncClient(Environment.PRODUCTION) as client: auth = await client.authentication.with_profile("prod-token") ``` + + + ## Recommended flow @@ -251,10 +266,11 @@ separately for transport failures before KSeF returns a response. ```python import httpx -from ksef2 import KSeFException +from ksef2 import Client, Environment, KSeFException try: - auth = client.authentication.with_test_certificate(nip="5261040828") + with Client(Environment.TEST) as client: + auth = client.authentication.with_test_certificate(nip="5261040828") except KSeFException as exc: print(exc) except httpx.HTTPError as exc: @@ -263,6 +279,18 @@ except httpx.HTTPError as exc: ## Next workflows -- [Send invoices](sending-invoices.mdx) -- [Query invoices](querying-invoices.mdx) -- [Download invoices](downloading-invoices.mdx) + + + diff --git a/docs/en/how-to-guides/build-fa3-invoices.mdx b/docs/en/how-to-guides/build-fa3-invoices.mdx new file mode 100644 index 0000000..f10848b --- /dev/null +++ b/docs/en/how-to-guides/build-fa3-invoices.mdx @@ -0,0 +1,484 @@ +--- +title: Build FA(3) Invoices +description: Generate complete FA(3) invoice models and XML with the fluent builder. +--- + +import { Aside, LinkCard, Steps } from '@astrojs/starlight/components'; + +Use `FA3InvoiceBuilder` when your application owns invoice data as Python +objects and you want the SDK to produce typed FA(3) models and XML. The builder +uses public SDK field names, then produces the same `KsefInvoice` aggregate used +by the FA(3) mappers. + + + +## Builder shape + +The builder has three levels: + +| Level | What it owns | Common methods | +| --- | --- | --- | +| Root invoice | Header, seller, buyer, third parties, footer, attachment, and final output. | `header()`, `seller()`, `buyer()`, `third_party()`, `footer()`, `attachment()`, `build()`, `to_xml()` | +| Body path | The FA(3) invoice kind and body-wide fields. | `standard()`, `simplified()`, `correction()`, `advance()`, `settlement()`, `correction_advance()`, `correction_settlement()` | +| Nested sections | Repeating or optional body details. | `rows()`, `payment()`, `annotations()`, `correction()`, `order()`, `advance()`, `settlement()`, `transaction()` | + +Each nested builder returns to its parent with `.done()`. The root builder can +produce a public model with `build()`, a generated FA(3) schema model with +`to_spec()`, or XML text with `to_xml()`. + +## Build a standard invoice + +Start with the root invoice parties, choose an invoice path with `.standard()`, +then add nested body sections. + +```python +from datetime import date +from decimal import Decimal + +from ksef2.fa3 import FA3InvoiceBuilder, VatRate + +builder = ( + FA3InvoiceBuilder() + .header(system_info="ksef2 builder guide") + .seller( + name="Demo Seller Sp. z o.o.", + tax_id="5261040828", + country_code="PL", + address_line_1="Prosta 1", + address_line_2="00-001 Warszawa", + ) + .buyer( + name="Demo Buyer Sp. z o.o.", + tax_id="5250001009", + country_code="PL", + address_line_1="Kwiatowa 2", + address_line_2="00-002 Warszawa", + ) + .standard() + .issue_date(date(2026, 6, 24)) + .issue_place("Warszawa") + .invoice_number("FV/1/2026") + .rows() + .add_line( + name="Implementation service", + quantity=Decimal("2"), + unit_price_net=Decimal("150.00"), + vat_rate=VatRate.VAT_23, + ) + .add_line( + name="Support package", + quantity=Decimal("1"), + unit_price_net=Decimal("50.00"), + vat_rate=VatRate.VAT_8, + ) + .done() + .payment() + .via("bank_transfer") + .due_on(date(2026, 7, 8)) + .bank_account("PL10101010101010101010101010") + .done() + .annotations() + .split_payment() + .done() + .done() +) +``` + +Call `build()` when you want the typed public invoice model. + +```python +invoice = builder.build() + +print(invoice.body.invoice_number) +# FV/1/2026 +print(invoice.total_net) +# 350.00 +print(invoice.total_vat) +# 73.00 +print(invoice.total_gross) +# 423.00 +``` + +## Choose the invoice path + +Use one of the body selectors after `header()`, `seller()`, and `buyer()`: + +| Selector | Use for | Typical nested sections | +| --- | --- | --- | +| `standard()` | Regular VAT invoices. | `rows()`, `payment()`, `annotations()`, `transaction()`, `settlement()` | +| `simplified()` | Simplified invoices. | `rows()`, `payment()`, `annotations()`, `settlement()` | +| `correction()` | Corrections to previous invoices. | `rows()`, `correction()`, `payment()`, `annotations()` | +| `advance()` | Advance-payment invoices. | `order()`, `payment()`, `advance()`, `annotations()`, `transaction()` | +| `settlement()` | Settlement invoices for advances. | `rows()`, `payment()`, `advance()`, `settlement()`, `transaction()` | +| `correction_advance()` | Corrections to advance-payment invoices. | `order()`, `correction()`, `advance()`, `payment()` | +| `correction_settlement()` | Corrections to settlement invoices. | `rows()`, `correction()`, `advance()`, `settlement()`, `payment()` | + +All selectors share body-wide methods such as `currency()`, `issue_date()`, +`issue_place()`, `invoice_number()`, `date_of_supply()`, `billing_period()`, +`add_description()`, `mark_fp()`, `related_party_transaction()`, and +`summary_overrides()`. + +## Add invoice rows + +Use `rows().add_line()` for normal application data. The builder calculates +missing row totals from `quantity`, one unit price, and VAT classification. +Provide either `unit_price_net` or `unit_price_gross`, not both. + +```python +builder = ( + FA3InvoiceBuilder() + .header(system_info="rows example") + .seller( + name="Demo Seller Sp. z o.o.", + tax_id="5261040828", + country_code="PL", + address_line_1="Prosta 1", + ) + .buyer( + name="Demo Buyer Sp. z o.o.", + tax_id="5250001009", + country_code="PL", + address_line_1="Kwiatowa 2", + ) + .standard() + .issue_date(date(2026, 6, 24)) + .invoice_number("FV/2/2026") + .rows() + .add_line( + name="Gross-priced service", + quantity=Decimal("2"), + unit_price_gross=Decimal("123.00"), + vat_rate=VatRate.VAT_23, + ) + .done() + .done() +) +``` + +Use `add_line_model()` or `replace_lines()` when another layer already created +`InvoiceRow` models. Use `summary_overrides()` when a business case requires +official FA(3) summary values that cannot be derived from row arithmetic. + +## Add payments and annotations + +The payment builder supports a single payment form, due dates, partial payments, +bank accounts, factor accounts, discounts, payment links, and IP KSeF data. + +```python +_ = ( + builder.standard() + .payment() + .via("bank_transfer") + .due_on(date(2026, 7, 8)) + .add_partial_payment( + amount=Decimal("50.00"), + payment_date=date(2026, 6, 24), + payment_form="card", + ) + .bank_account( + "PL10101010101010101010101010", + bank_name="Demo Bank S.A.", + account_description="PLN operating account", + ) + .done() + .annotations() + .split_payment() + .cash_accounting(False) + .margin_procedure("travel_agency") + .done() + .done() +) +``` + +Most annotation methods accept booleans or enum-like strings and write the +corresponding FA(3) flag only when it is needed. + +## Build corrections + +Correction invoices stay in the fluent builder. Use the body selector +`.correction()`, add negative or before/after rows as needed, then open the +nested `.correction()` section to describe the correction reason and corrected +invoice references. + +```python +from datetime import date +from decimal import Decimal + +from ksef2.fa3 import FA3InvoiceBuilder, InvoiceSummaryOverrides, VatRate + +correction = ( + FA3InvoiceBuilder() + .header(system_info="ksef2 correction guide") + .seller( + name="Demo Seller Sp. z o.o.", + tax_id="5261040828", + country_code="PL", + address_line_1="Prosta 1", + ) + .buyer( + name="Demo Buyer Sp. z o.o.", + tax_id="5250001009", + country_code="PL", + address_line_1="Kwiatowa 2", + ) + .correction() + .issue_date(date(2026, 7, 1)) + .issue_place("Warszawa") + .invoice_number("FK/1/2026") + .summary_overrides( + InvoiceSummaryOverrides( + base_rate_net_total=Decimal("-100.00"), + base_rate_vat_total=Decimal("-23.00"), + total_gross=Decimal("-123.00"), + ) + ) + .rows() + .add_line( + name="Implementation service correction", + quantity=Decimal("1"), + unit_price_net=Decimal("-100.00"), + vat_rate=VatRate.VAT_23, + ) + .done() + .correction() + .reason("Price correction") + .effect_type("other_date") + .add_corrected_invoice( + issue_date=date(2026, 6, 24), + invoice_number="FV/1/2026", + ksef_id="5261040828-20260624-ABCDEF-ABCDEF-FF", + ) + .done() + .done() + .build() +) +``` + +The correction section also supports corrected invoice periods, corrected +seller data, corrected buyers, and model-based input methods when you already +hold public FA(3) models. + +## Build advance and settlement invoices + +Advance invoices combine the body path with an order section, payment details, +and optional advance references. Settlement invoices can then reference earlier +advance invoices. + +```python +advance_xml = ( + FA3InvoiceBuilder() + .header(system_info="advance guide") + .seller( + name="Demo Developer S.A.", + tax_id="5261040828", + country_code="PL", + address_line_1="Sadowa 1", + ) + .buyer( + name="Demo Buyer", + tax_id="5250001009", + country_code="PL", + address_line_1="Kwiatowa 2", + ) + .advance() + .issue_date(date(2026, 8, 1)) + .invoice_number("FZ/1/2026") + .order(declared_total=Decimal("324000.00")) + .add_line( + name="Apartment reservation", + quantity=Decimal("1"), + unit_price_net=Decimal("300000.00"), + vat_rate=VatRate.VAT_8, + gross_amount=Decimal("324000.00"), + ) + .done() + .payment() + .via("bank_transfer") + .already_paid(date(2026, 8, 1)) + .done() + .done() + .to_xml() +) +``` + +```python +settlement_xml = ( + FA3InvoiceBuilder() + .header(system_info="settlement guide") + .seller( + name="Demo Developer S.A.", + tax_id="5261040828", + country_code="PL", + address_line_1="Sadowa 1", + ) + .buyer( + name="Demo Buyer", + tax_id="5250001009", + country_code="PL", + address_line_1="Kwiatowa 2", + ) + .settlement() + .issue_date(date(2026, 9, 1)) + .invoice_number("FR/1/2026") + .rows() + .add_line( + name="Apartment settlement", + quantity=Decimal("1"), + unit_price_net=Decimal("185185.19"), + vat_rate=VatRate.VAT_8, + ) + .done() + .advance() + .add_invoice_reference(ksef_id="5261040828-20260801-ABCDEF-ABCDEF-FF") + .done() + .payment() + .via("bank_transfer") + .due_on(date(2026, 9, 15)) + .done() + .done() + .to_xml() +) +``` + +## Add root-level details + +Root-level details are outside the invoice body and can be added before or after +the body path. + +| Need | Fluent method | Model method | +| --- | --- | --- | +| Existing seller, buyer, header, footer, or attachment model | Use the specific fluent builder when you want to edit fields. | `header_model()`, `seller_model()`, `buyer_model()`, `footer_model()`, `attachment_model()` | +| Third-party subject | `third_party(...)` | `add_third_party_model()`, `replace_third_parties()` | +| Footer registry or legal note | `footer().add_information().add_registry().done()` | `footer_model()` | +| Attachment with data blocks or tables | `attachment().build_data_block()...done().done()` | `attachment_model()` | + +```python +builder = ( + builder + .third_party( + name="Additional buyer", + tax_id="3333333333", + address_country_code="PL", + address_line_1="Polna 3", + role="additional_buyer", + share_percentage=Decimal("50"), + ) + .footer() + .add_information("Invoice generated electronically.") + .add_registry(krs="0000123456", regon="123456789") + .done() + .attachment() + .build_data_block() + .set_header("Delivery specification") + .add_paragraph("Goods were inspected before dispatch.") + .build_table() + .set_columns(["txt", "decimal"], names=["Service", "Net amount"]) + .add_row(["Consulting", "1000.00"]) + .done() + .done() + .done() +) +``` + +## Generate XML + +The builder can produce three forms: + +- `build()` returns `KsefInvoice`. +- `to_spec()` returns the generated FA(3) `Faktura` schema model. +- `to_xml()` returns an XML string. + +```python +from pathlib import Path + +invoice = builder.build() +spec = builder.to_spec() +xml_text = builder.to_xml() +Path("invoice.xml").write_text(xml_text, encoding="utf-8") +``` + +When sending through an online or batch session, pass XML bytes. + +```python +from ksef2 import FormSchema + +invoice_xml = builder.to_xml().encode("utf-8") + +with auth.online_session(form_code=FormSchema.FA3) as session: + sent = session.send_invoice(invoice_xml=invoice_xml) + print(sent.reference_number) +``` + +## Save and reload drafts + +Persist incomplete builder state with `dump_state()` or `dump_state_json()`. +Reload it later with `from_state()` or `from_state_json()`. + +```python +from ksef2.fa3 import FA3InvoiceBuilder, KsefInvoiceDraft + +draft_json = builder.dump_state_json(indent=2) +draft = KsefInvoiceDraft.model_validate_json(draft_json) +restored = FA3InvoiceBuilder.from_state(draft) +``` + +If you already have a complete `KsefInvoice`, reopen it in the builder with +`FA3InvoiceBuilder.from_invoice(invoice)`. + +## Troubleshoot builder errors + +| Error | Meaning | Fix | +| --- | --- | --- | +| `Invoice header is required but not set.` | The root builder has no header. | Call `header()` or `header_model()` before `build()`, `to_spec()`, or `to_xml()`. | +| `Invoice seller is required but not set.` | The root seller is missing. | Call `seller()` or `seller_model()`. | +| `Invoice buyer is required but not set.` | The root buyer is missing. | Call `buyer()` or `buyer_model()`. | +| `Invoice body is required but not set.` | No body path was selected and completed. | Call one body selector such as `standard()` or `correction()`, fill required fields, and return with `.done()`. | +| `Provide either unit_price_net or unit_price_gross, not both.` | A row has ambiguous pricing input. | Pass only one unit price and let the builder compute row totals. | +| `... details are empty. Set at least one field before calling done().` | A nested optional section was opened but left empty. | Add section data before `.done()` or do not create that section. | + +## Recommended flow + + + +1. Create `FA3InvoiceBuilder()` and set header, seller, and buyer. + +2. Choose the body path with `standard()`, `correction()`, `advance()`, or the + other invoice selector that matches the business document. + +3. Add nested sections such as `rows()`, `payment()`, `annotations()`, + `correction()`, `order()`, `footer()`, or `attachment()`. + +4. Use `build()` for the public model during validation and `to_xml()` for the + send path. + +5. Encode the XML string to bytes before passing it to session or batch APIs. + + + +## Related pages + + + + + diff --git a/docs/en/how-to-guides/client-setup.mdx b/docs/en/how-to-guides/client-setup.mdx new file mode 100644 index 0000000..6ebe93a --- /dev/null +++ b/docs/en/how-to-guides/client-setup.mdx @@ -0,0 +1,191 @@ +--- +title: Client Setup +description: Create ksef2 sync and async clients, configure transport behavior, and choose authenticated branches. +--- + +import { Aside, LinkCard, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; + +The root `Client` owns HTTP transport configuration and exposes unauthenticated +public branches. Authenticate once for a KSeF context, then pass the +authenticated client into workflow code. + +## Choose sync or async + + + + +```python +from ksef2 import Client, Environment + +with Client(Environment.TEST) as client: + auth = client.authentication.with_test_certificate(nip="5261040828") +``` + + + + +```python +from ksef2 import AsyncClient, Environment + +async with AsyncClient(Environment.TEST) as client: + auth = await client.authentication.with_test_certificate(nip="5261040828") +``` + + + + +Use `Environment.DEMO` or `Environment.PRODUCTION` outside local TEST +workflows. + +## Manage lifecycle + +The root client owns SDK-managed HTTP resources. Prefer a context manager for +scripts and jobs: + +```python +from ksef2 import Client, Environment + +with Client(Environment.TEST) as client: + auth = client.authentication.with_test_certificate(nip="5261040828") +``` + +If a framework asks for a yielded dependency, put the `yield` inside the +dependency function and close the client in `finally`: + +```python +from collections.abc import Iterator + +from ksef2 import Client, Environment + + +def get_client() -> Iterator[Client]: + client = Client(Environment.TEST) + try: + yield client + finally: + client.close() +``` + +Async applications use the same boundary with `async with`: + +```python +from ksef2 import AsyncClient, Environment + +async with AsyncClient(Environment.TEST) as client: + auth = await client.authentication.with_test_certificate(nip="5261040828") +``` + +Online and batch sessions are lifecycle boundaries too. Use a session context +manager so the SDK closes the remote session when the block exits: + +```python +from ksef2 import FormSchema + +with auth.online_session(form_code=FormSchema.FA3) as session: + status = session.send_invoice_and_wait(invoice_xml=invoice_xml) +``` + +When credentials live in a CLI-compatible profile, create the root client for +the profile environment and authenticate through `with_profile()`: + +```python +from ksef2 import Client, Environment + +client = Client(Environment.PRODUCTION) +auth = client.authentication.with_profile("prod-token") +``` + +## Public root branches + +The root client is useful before authentication: + +```python +certificates = client.encryption.get_certificates() +providers = client.peppol.query() +``` + +The TEST-only branch is also on the root client: + +```python +client.testdata.create_subject( + nip="5261040828", + subject_type="vat_group", + description="Sandbox company", +) +``` + +## Authenticated branches + +After authentication, use the branch that matches the task: + +```python +invoices = auth.invoices +batch = auth.batch +tokens = auth.tokens +permissions = auth.permissions +certificates = auth.certificates +limits = auth.limits +sessions = auth.sessions +invoice_sessions = auth.invoice_sessions +``` + + + +## Recommended flow + + + +1. Read environment and transport settings in your application boundary. + +2. Create one root client for the selected KSeF environment. + +3. Use root branches only for public lookup or TEST data setup. + +4. Authenticate once for the context that owns the operation. + +5. Pass the authenticated client to invoice, token, permission, certificate, + limits, and session workflows. + + + +## Reference + + + + + + + + diff --git a/docs/en/how-to-guides/configure-certificate-store.mdx b/docs/en/how-to-guides/configure-certificate-store.mdx new file mode 100644 index 0000000..88f2753 --- /dev/null +++ b/docs/en/how-to-guides/configure-certificate-store.mdx @@ -0,0 +1,149 @@ +--- +title: Configure a Certificate Store +description: Configure the SDK certificate cache used by token authentication, sessions, and invoice exports. +--- + +import { Aside, LinkCard, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; + +The SDK stores KSeF public encryption certificates on the root client. Token +authentication, online and batch sessions, and invoice exports reuse that store +when they need to encrypt key material for KSeF. + +Use the default in-memory store for scripts and workers that can refresh +certificates from KSeF. Provide a custom store when certificates must be shared +across processes or persisted in application storage. + +## Use the default store + +`Client` and `AsyncClient` create `CertificateStore` automatically. The default +store refreshes valid cached certificates after 24 hours and refreshes +immediately when the required certificate usage is missing. + +Configure a different refresh interval at root-client construction: + + + + +```python +from datetime import timedelta + +from ksef2 import CertificateStore, Client, Environment + +store = CertificateStore(refresh_after=timedelta(hours=6)) + +with Client(Environment.PRODUCTION, certificate_store=store) as client: + auth = client.authentication.with_profile("prod-token") +``` + + + + +```python +from datetime import timedelta + +from ksef2 import AsyncClient, CertificateStore, Environment + +store = CertificateStore(refresh_after=timedelta(hours=6)) + +async with AsyncClient(Environment.PRODUCTION, certificate_store=store) as client: + auth = await client.authentication.with_profile("prod-token") +``` + + + + +Use `refresh_after=None` only for short-lived clients where fetch-once behavior +is intentional: + +```python +from ksef2 import CertificateStore + +store = CertificateStore(refresh_after=None) +``` + + + +## Provide a custom store + +Custom stores implement `CertificateStoreProtocol`. The SDK owns remote +fetching; the store owns persistence, valid-certificate selection, and freshness +decisions. + +```python +from collections.abc import Iterable +from datetime import datetime + +from ksef2 import CertificateStoreProtocol, Client, Environment +from ksef2.domain.models.encryption import ( + CertUsage, + CertUsageEnum, + PublicKeyCertificate, +) + + +class DatabaseCertificateStore: + def load(self, certs: Iterable[PublicKeyCertificate]) -> None: + """Replace stored public certificates after the SDK fetches them.""" + ... + + def get_valid( + self, + usage: CertUsage | CertUsageEnum | str, + ) -> PublicKeyCertificate: + """Return a currently valid certificate for the requested usage.""" + ... + + def needs_refresh( + self, + usage: CertUsage | CertUsageEnum | str, + *, + at: datetime | None = None, + ) -> bool: + """Return True when the SDK should fetch certificates from KSeF.""" + ... + + +store: CertificateStoreProtocol = DatabaseCertificateStore() +client = Client(Environment.PRODUCTION, certificate_store=store) +``` + +## Recommended flow + + + +1. Start with the default `CertificateStore`. + +2. Set `refresh_after` when your application has a stricter certificate rotation + or startup policy. + +3. Implement `CertificateStoreProtocol` only when certificates must survive + process restarts or be shared by multiple workers. + +4. Keep remote KSeF fetching in the SDK client and keep persistence in the + store. + + + +## Related pages + + + + + + diff --git a/docs/en/how-to-guides/configure-permissions.mdx b/docs/en/how-to-guides/configure-permissions.mdx new file mode 100644 index 0000000..6ccf53a --- /dev/null +++ b/docs/en/how-to-guides/configure-permissions.mdx @@ -0,0 +1,217 @@ +--- +title: Configure Permissions +description: Grant, query, revoke, and monitor KSeF permissions with ksef2. +--- + +import { Aside, LinkCard, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; + +Use `auth.permissions` when the authenticated context needs to grant, inspect, +or revoke KSeF permissions. Permission changes are asynchronous: grant and +revoke methods return an operation reference first. + +## Grant permissions + +Pick the grant method that matches the target subject. Keep the permission set +as small as the workflow allows. + + + + +```python +operation = auth.permissions.grant_person( + subject_type="pesel", + subject_value="90010112345", + permissions=["invoice_read"], + description="Read invoices", + first_name="Jan", + last_name="Kowalski", +) + +# GrantPermissionsResponse +# { +# "reference_number": "20260625-PERM-..." +# } +``` + + + + +```python +from ksef2.models import EntityPermission + +operation = auth.permissions.grant_entity( + subject_value="1234567890", + permissions=[ + EntityPermission(type="invoice_read", can_delegate=False), + ], + description="Accounting office read access", + entity_name="Accounting Sp. z o.o.", +) +``` + + + + +```python +operation = auth.permissions.grant_authorization( + subject_type="nip", + subject_value="1234567890", + permission="self_invoicing", + description="Self-invoicing agreement", + entity_name="Partner Sp. z o.o.", +) +``` + + + + +## Check operation status + +Use the returned `reference_number` until KSeF reports the permission operation +result. + +```python +status = auth.permissions.get_operation_status( + reference_number=operation.reference_number, +) + +# PermissionOperationStatusResponse +# { +# "status": { +# "code": 200, +# "description": "Completed" +# } +# } +``` + + + +## Query permissions + +Query methods have separate shapes because KSeF distinguishes personal, +person, entity, authorization, EU entity, subordinate entity, and subunit +permission records. + + + + +```python +from ksef2.models import PersonalPermissionsQuery + +page = auth.permissions.query_personal( + query=PersonalPermissionsQuery( + permission_types=["invoice_read"], + permission_state="active", + ), +) + +for permission in page.permissions: + print(permission.id, permission.permission_type, permission.permission_state) +``` + + + + +```python +from ksef2.models import EntityPermissionsQuery + +page = auth.permissions.query_entities( + query=EntityPermissionsQuery(context_type="nip", context_value="5261040828"), +) + +for permission in page.permissions: + print(permission.id, permission.permission_type, permission.can_delegate) +``` + + + + +```python +from ksef2.models import AuthorizationPermissionsQuery + +page = auth.permissions.query_authorizations( + query=AuthorizationPermissionsQuery( + query_type="granted", + permission_types=["self_invoicing"], + ), +) + +for grant in page.authorization_grants: + print(grant.id, grant.authorization_scope, grant.authorized_entity_value) +``` + + + + +## Revoke permissions + +Use the permission id returned by a query. Revocation also returns an operation +reference. + +```python +operation = auth.permissions.revoke_common(permission_id="permission-id") +status = auth.permissions.get_operation_status( + reference_number=operation.reference_number, +) +print(status.status.code, status.status.description) +``` + +For authorization grants, use the authorization-specific revocation method: + +```python +operation = auth.permissions.revoke_authorization( + permission_id="authorization-id", +) +``` + +## Check attachment permission + +```python +status = auth.permissions.get_attachment_permission_status() + +# AttachmentPermissionStatus +# { +# "is_attachment_allowed": true, +# "revoked_date": null +# } +``` + +## Recommended flow + + + +1. Grant the smallest permission set required by the target subject. + +2. Persist the operation `reference_number`. + +3. Check operation status before exposing the permission as active. + +4. Query permissions to collect ids for audits or revocation. + +5. Revoke by permission id when access should end, then check the revoke + operation status. + + + +## Next workflows + + + + + + diff --git a/docs/en/how-to-guides/download-invoices.mdx b/docs/en/how-to-guides/download-invoices.mdx new file mode 100644 index 0000000..ad5d113 --- /dev/null +++ b/docs/en/how-to-guides/download-invoices.mdx @@ -0,0 +1,199 @@ +--- +title: Download Invoices +description: Download processed invoice XML directly or through encrypted export packages with ksef2. +--- + +import { Aside, LinkCard, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; + +Use `auth.invoices` when you need invoice content after KSeF has processed it. +The examples below assume you already have an authenticated client named `auth`. + +## Download one invoice + +If you already have a KSeF number, direct download is the shortest path. + +```python +from pathlib import Path + +ksef_number = "1234567890-20260625-..." + +xml_bytes = auth.invoices.download_invoice(ksef_number=ksef_number) +Path("invoice.xml").write_bytes(xml_bytes) +``` + +If the invoice was just sent, KSeF may need time before the processed XML is +downloadable. Use the waiting helper when your workflow should block until the +document is available. + +```python +from pathlib import Path + +ksef_number = "1234567890-20260625-..." + +xml_bytes = auth.invoices.wait_for_invoice_download( + ksef_number=ksef_number, + timeout=120.0, + poll_interval=2.0, +) +Path("invoice.xml").write_bytes(xml_bytes) +``` + +## Build an export filter + +Use exports for larger downloads. Exports use `InvoicesFilter`, the same filter +shape used by metadata queries. + +```python +from datetime import datetime, timedelta, timezone + +from ksef2.models import InvoicesFilter + +now = datetime.now(tz=timezone.utc) + +filters = InvoicesFilter.for_buyer( + date_type="permanent_storage", + date_from=now - timedelta(days=1), + date_to=now, + restrict_to_permanent_storage_hwm_date=True, +) +``` + + + +## Export many invoices + +Schedule the export, wait for the package, then fetch the decrypted ZIP parts. + + + + +```python +from pathlib import Path + +export = auth.invoices.schedule_export(filters=filters) + +# ExportHandle(reference_number="...", aes_key=, iv=) +# Keep this object private. It contains decryption material. + +package = auth.invoices.wait_for_export_package( + reference_number=export.reference_number, + timeout=300.0, +) + +# InvoicePackage +# { +# "invoice_count": 3, +# "size": 14820, +# "is_truncated": false, +# "last_permanent_storage_date": "2026-06-25T09:58:21Z", +# "permanent_storage_hwm_date": "2026-06-25T10:00:00Z", +# "parts": [ +# { +# "ordinal_number": 1, +# "part_name": "package-1.zip", +# "expiration_date": "2026-06-26T10:00:00Z" +# } +# ] +# } + +saved_paths = auth.invoices.fetch_package( + package=package, + export=export, + target_directory=Path("downloads"), +) + +for path in saved_paths: + print(path) +``` + + + + +```python +export = auth.invoices.schedule_export(filters=filters) +package = auth.invoices.wait_for_export_package( + reference_number=export.reference_number, + timeout=300.0, +) + +zip_parts = auth.invoices.fetch_package_bytes(package=package, export=export) + +for zip_part in zip_parts: + print(len(zip_part)) +``` + + + + +```python +zip_parts = auth.invoices.export_and_download( + filters=filters, + timeout=300.0, + poll_interval=2.0, +) + +for zip_part in zip_parts: + print(len(zip_part)) +``` + + + + + + + + +## After sending invoices + +Keep sending, processing, and retrieval as separate phases. + + + +1. Send invoice XML through an online or batch session. + +2. Poll session or invoice status until KSeF reports a final accepted result. + +3. Persist the returned `ksef_number` values. + +4. Download one processed XML document by `ksef_number`, or build an export + filter for a larger time window. + + + +## Next workflows + + + + + + + + diff --git a/docs/en/how-to-guides/get-status-and-upo.mdx b/docs/en/how-to-guides/get-status-and-upo.mdx new file mode 100644 index 0000000..4bf1bae --- /dev/null +++ b/docs/en/how-to-guides/get-status-and-upo.mdx @@ -0,0 +1,257 @@ +--- +title: Get Status and UPO +description: Check online and batch session status, list session invoices, and download UPO documents. +--- + +import { Aside, LinkCard, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; + +Use this page after you have submitted invoice XML. The examples assume you +already have an authenticated client named `auth`. + +Status calls return SDK models. UPO calls return XML bytes. + +## Online invoice status + +For online sending, `send_invoice()` returns a session invoice reference. Use +that reference to poll the invoice result and download invoice UPO. + +```python +from pathlib import Path + +from ksef2 import FormSchema + +with auth.online_session(form_code=FormSchema.FA3) as session: + sent = session.send_invoice(invoice_xml=invoice_xml) + + invoice_status = session.wait_for_invoice_ready( + invoice_reference_number=sent.reference_number, + timeout=120.0, + poll_interval=2.0, + ) + + # SessionInvoiceStatusResponse + # { + # "ordinal_number": 1, + # "reference_number": "20260625-ABCD-EF1234567890", + # "invoice_number": "FV/42/2026", + # "ksef_number": "1234567890-20260625-...", + # "status": { + # "code": 200, + # "description": "Processed" + # } + # } + + upo_xml = session.get_invoice_upo_by_reference( + invoice_reference_number=sent.reference_number, + ) + Path("upo.xml").write_bytes(upo_xml) +``` + +Inside the same session block, if you already have the KSeF number, you can +download invoice UPO by KSeF number instead: + +```python +ksef_number = invoice_status.ksef_number +if ksef_number is None: + raise RuntimeError("KSeF did not assign an invoice number.") + +upo_xml = session.get_invoice_upo_by_ksef_number( + ksef_number=ksef_number, +) +``` + + + +## Online session pages + +Use session pages when you need to inspect everything submitted in an online +session instead of one invoice reference. + + + + +```python +page = session.list_invoices(page_size=100) + +for invoice in page.invoices: + print(invoice.reference_number, invoice.ksef_number) +``` + + + + +```python +page = session.list_failed_invoices(page_size=100) + +for invoice in page.invoices: + print(invoice.reference_number, invoice.status.code, invoice.status.details) +``` + + + + +## Batch status and UPO + +For batch sending, keep the returned `BatchSessionResumeState`. The batch service can +poll by state or by plain session reference number. + +```python +from pathlib import Path + +final_status = auth.batch.wait_for_completion( + session=state, + timeout=300.0, + poll_interval=2.0, +) + +# SessionStatusResponse +# { +# "status": { +# "code": 200, +# "description": "Processed" +# }, +# "invoice_count": 10, +# "successful_invoice_count": 9, +# "failed_invoice_count": 1, +# "upo": { +# "pages": [ +# { +# "reference_number": "upo-page-reference", +# "download_url_expiration_date": "2026-06-26T10:00:00Z" +# } +# ] +# } +# } + +if final_status.upo is not None and final_status.upo.pages: + upo_reference_number = final_status.upo.pages[0].reference_number + upo_xml = auth.batch.get_upo( + session=state, + upo_reference_number=upo_reference_number, + ) + Path("batch-upo.xml").write_bytes(upo_xml) +``` + + + +## List accepted and failed batch invoices + +Accepted and failed invoices are separate pages. Read both when reconciling a +batch. + + + + +```python +page = auth.batch.list_invoices(session=state, page_size=100) + +for invoice in page.invoices: + print(invoice.invoice_file_name, invoice.ksef_number, invoice.status.description) +``` + + + + +```python +page = auth.batch.list_failed_invoices(session=state, page_size=100) + +for invoice in page.invoices: + print(invoice.invoice_file_name, invoice.status.code, invoice.status.details) +``` + + + + +When a page has `continuation_token`, pass it to the next call: + +```python +page = auth.batch.list_invoices(session=state, page_size=100) + +while page.continuation_token is not None: + page = auth.batch.list_invoices( + session=state, + page_size=100, + continuation_token=page.continuation_token, + ) +``` + +## Find sessions after restart + +Use `auth.invoice_sessions` when you need to find online or batch sessions after +the original sender process has exited. + + + + +```python +page = auth.invoice_sessions.query( + session_type="online", + statuses=["in_progress", "succeeded"], +) + +for item in page.sessions: + print(item.reference_number, item.status.code, item.total_invoice_count) +``` + + + + +```python +for page in auth.invoice_sessions.all(session_type="batch"): + for item in page.sessions: + print(item.reference_number, item.status.description) +``` + + + + +## Recommended flow + + + +1. Persist session references and invoice reference numbers when sending. + +2. Poll the specific invoice, online session, or batch session that matches the + question you are answering. + +3. Store accepted and failed invoice details. + +4. Download UPO XML and store it with your audit records. + +5. Use `auth.invoice_sessions` to recover session references after process + restart. + + + +## Next workflows + + + + + + + + diff --git a/docs/en/how-to-guides/inspect-encryption-certificates.mdx b/docs/en/how-to-guides/inspect-encryption-certificates.mdx new file mode 100644 index 0000000..a2954ce --- /dev/null +++ b/docs/en/how-to-guides/inspect-encryption-certificates.mdx @@ -0,0 +1,121 @@ +--- +title: Inspect Encryption Certificates +description: Read public KSeF encryption certificates used by encrypted invoice and export workflows. +--- + +import { Aside, LinkCard, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; + +Use `client.encryption` when you need a startup check, diagnostics, or a custom +certificate cache. Most invoice workflows load public encryption certificates +automatically. + +## Fetch certificates + +`client.encryption` is a root-client branch, so it does not require an +authenticated client. + + + + +```python +certificates = client.encryption.get_certificates() + +# PublicKeyCertificate +# { +# "public_key_id": "12345", +# "certificate_id": "abcde", +# "valid_from": "2026-06-01T00:00:00Z", +# "valid_to": "2026-12-01T00:00:00Z", +# "usage": ["ksef_token_encryption", "symmetric_key_encryption"] +# } + +for certificate in certificates: + print(certificate.public_key_id, certificate.usage, certificate.valid_to) +``` + + + + +```python +certificates = client.encryption.get_certificates( + usage=["symmetric_key_encryption"], +) + +for certificate in certificates: + print(certificate.public_key_id, certificate.valid_to) +``` + + + + +```python +certificates = client.encryption.get_certificates( + usage=["ksef_token_encryption"], +) + +for certificate in certificates: + print(certificate.public_key_id, certificate.valid_to) +``` + + + + +## Preload before encrypted workflows + +High-level online, batch, token-authentication, and export helpers use these +public certificates when they encrypt local key material for KSeF. Preloading is +useful when you want startup diagnostics before a worker accepts jobs. + +```python +required_usage = "symmetric_key_encryption" +certificates = client.encryption.get_certificates(usage=[required_usage]) + +if not certificates: + raise RuntimeError(f"No KSeF certificate supports {required_usage}.") +``` + + + +## Recommended flow + + + +1. Let high-level invoice workflows load certificates lazily by default. + +2. Add a startup check only when certificate availability should fail fast. + +3. Check the usage your workflow needs: token encryption or symmetric-key + encryption. + +4. Alert and retry later if no valid certificate is available. + + + +## Next workflows + + + + + + + + diff --git a/docs/en/how-to-guides/manage-certificates.mdx b/docs/en/how-to-guides/manage-certificates.mdx new file mode 100644 index 0000000..4d919da --- /dev/null +++ b/docs/en/how-to-guides/manage-certificates.mdx @@ -0,0 +1,153 @@ +--- +title: Manage Certificates +description: Check certificate limits, enroll certificates, query issued certificates, and revoke certificates. +--- + +import { Aside, LinkCard, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; + +Use `auth.certificates` for KSeF certificate enrollment, search, retrieval, and +revocation. The SDK sends CSR and lifecycle requests. Your certificate tooling +still owns private-key generation and private-key storage. + +## Check limits and enrollment data + +```python +limits = auth.certificates.get_limits() + +# CertificateLimitsResponse +# { +# "can_request": true, +# "enrollment_limit": 10, +# "enrollment_remaining": 9, +# "certificate_limit": 10, +# "certificate_remaining": 8 +# } + +subject = auth.certificates.get_enrollment_data() + +# CertificateEnrollmentData +# { +# "common_name": "Example subject", +# "iso_country_code": "PL", +# "organization_identifier": "VATPL-5261040828" +# } +``` + +Use the enrollment data when building the CSR in your certificate tooling. + +## Enroll a certificate + +Submit a CSR and persist the returned enrollment reference. + +```python +csr = """-----BEGIN CERTIFICATE REQUEST----- +... +-----END CERTIFICATE REQUEST-----""" + +enrollment = auth.certificates.enroll( + certificate_name="billing-service", + certificate_type="authentication", + csr=csr, +) + +# CertificateEnrollmentResponse +# { +# "reference_number": "20260625-CERT-...", +# "timestamp": "2026-06-25T10:00:00Z" +# } +``` + +Then check the enrollment status: + +```python +status = auth.certificates.get_enrollment_status( + reference_number=enrollment.reference_number, +) + +# CertificateEnrollmentStatusResponse +# { +# "status_code": 200, +# "status_description": "Completed", +# "certificate_serial_number": "0123456789ABCDEF" +# } +``` + + + +## Query, retrieve, and revoke + + + + +```python +for certificate in auth.certificates.all(status="active"): + print(certificate.serial_number, certificate.name, certificate.valid_to) +``` + + + + +```python +result = auth.certificates.retrieve( + certificate_serial_numbers=["0123456789ABCDEF"], +) + +for certificate in result.certificates: + print(certificate.serial_number, certificate.certificate_type) +``` + + + + +```python +auth.certificates.revoke( + certificate_serial_number="0123456789ABCDEF", + reason="key_compromise", +) +``` + + + + +## Recommended flow + + + +1. Check certificate and enrollment quotas. + +2. Fetch enrollment subject data. + +3. Generate a private key and CSR outside the SDK. + +4. Submit enrollment and persist the reference number. + +5. Poll status, retrieve the certificate, and store it with the private key. + +6. Revoke certificates that are no longer valid or whose private key may be + compromised. + + + +## Next workflows + + + + + + diff --git a/docs/en/how-to-guides/manage-limits.mdx b/docs/en/how-to-guides/manage-limits.mdx new file mode 100644 index 0000000..8fd2892 --- /dev/null +++ b/docs/en/how-to-guides/manage-limits.mdx @@ -0,0 +1,149 @@ +--- +title: Manage Limits +description: Read and override KSeF context, subject, and API rate limits through ksef2. +--- + +import { Aside, LinkCard, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; + +Use `auth.limits` to inspect the effective limits KSeF applies to the current +authenticated context. Only use override methods from explicit administrative +workflows. + +## Read effective limits + +Read the current limits before choosing batch sizes, polling frequency, or +certificate enrollment workflows. + + + + +```python +context = auth.limits.get_context_limits() + +# ContextLimits +# { +# "online_session": { +# "max_invoice_size_mb": 10, +# "max_invoice_with_attachment_size_mb": 20, +# "max_invoices": 100 +# }, +# "batch_session": { +# "max_invoice_size_mb": 10, +# "max_invoice_with_attachment_size_mb": 20, +# "max_invoices": 1000 +# } +# } + +print(context.online_session.max_invoices) +print(context.batch_session.max_invoice_size_mb) +``` + + + + +```python +subject = auth.limits.get_subject_limits() + +print(subject.certificate) +print(subject.enrollment) +``` + + + + +```python +rate = auth.limits.get_api_rate_limits() + +print(rate.invoice_send.per_minute) +print(rate.invoice_metadata.per_minute) +print(rate.invoice_download.per_hour) +``` + + + + +## Override session limits + +Use session overrides only when the authenticated context is allowed to manage +limits and the change is part of a controlled test or administration procedure. + +```python +from ksef2.models import ContextLimits, SessionLimits + +limits = ContextLimits( + online_session=SessionLimits( + max_invoice_size_mb=10, + max_invoice_with_attachment_size_mb=20, + max_invoices=100, + ), + batch_session=SessionLimits( + max_invoice_size_mb=10, + max_invoice_with_attachment_size_mb=20, + max_invoices=1000, + ), +) + +auth.limits.set_session_limits(limits=limits) +``` + +Reset the override when the test or temporary change is done: + +```python +auth.limits.reset_session_limits() +``` + +## Use production rate defaults + +Use `set_production_rate_limits()` when a TEST-like environment should copy the +production API rate defaults. + +```python +auth.limits.set_production_rate_limits() + +rate = auth.limits.get_api_rate_limits() +print(rate.invoice_send.per_minute) +``` + + + +## Recommended flow + + + +1. Read effective limits before choosing batch size, upload strategy, or polling + frequency. + +2. Keep production defaults unless a controlled test or administrator workflow + requires an override. + +3. Apply overrides from explicit operations code. + +4. Verify the new limits after applying them. + +5. Reset temporary overrides after the test or maintenance window. + + + +## Next workflows + + + + + + diff --git a/docs/en/how-to-guides/manage-tokens.mdx b/docs/en/how-to-guides/manage-tokens.mdx new file mode 100644 index 0000000..870f9ff --- /dev/null +++ b/docs/en/how-to-guides/manage-tokens.mdx @@ -0,0 +1,139 @@ +--- +title: Manage Tokens +description: Generate, list, inspect, and revoke KSeF tokens through ksef2. +--- + +import { Aside, LinkCard, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; + +Use `auth.tokens` when an authenticated context needs to create or retire KSeF +tokens for automation. The examples assume you already have an authenticated +client named `auth`. + +## Generate a token + +Choose the smallest permission set required by the automation that will use the +token. + +```python +token = auth.tokens.generate( + permissions=["invoice_read"], + description="nightly invoice export", + timeout=60.0, + poll_interval=1.0, +) + +# GenerateTokenResponse +# { +# "reference_number": "20260625-TOKEN-...", +# "token": "eyJ..." +# } +``` + +`generate()` submits the token request and waits until KSeF reports the token as +active. The returned `token` value is the secret your automation will use for +token authentication later. + + + +## List tokens + +Use `list_page()` for one page or `list_all()` for a full audit pass. + + + + +```python +page = auth.tokens.list_page() + +# QueryTokensResponse +# { +# "continuation_token": null, +# "tokens": [ +# { +# "reference_number": "20260625-TOKEN-...", +# "description": "nightly invoice export", +# "requested_permissions": ["invoice_read"], +# "status": "active" +# } +# ] +# } + +for item in page.tokens: + print(item.reference_number, item.status, item.description) +``` + + + + +```python +for page in auth.tokens.list_all(): + for item in page.tokens: + print(item.reference_number, item.status, item.description) +``` + + + + +## Inspect or revoke one token + +Use the token reference number for later lifecycle operations. + +```python +status = auth.tokens.status(reference_number="20260625-TOKEN-...") + +# TokenStatusResponse +# { +# "reference_number": "20260625-TOKEN-...", +# "status": "active" +# } +``` + +```python +auth.tokens.revoke(reference_number="20260625-TOKEN-...") +``` + + + +## Recommended flow + + + +1. Choose the smallest permission set required by the automation. + +2. Generate the token in the authenticated context that should own it. + +3. Store the token value in a secret store and the reference number in metadata. + +4. List or inspect token references during audits. + +5. Revoke unused, expired, or compromised tokens. + + + +## Next workflows + + + + + + diff --git a/docs/en/how-to-guides/migrate-to-1-0-0.mdx b/docs/en/how-to-guides/migrate-to-1-0-0.mdx new file mode 100644 index 0000000..f6ddfc7 --- /dev/null +++ b/docs/en/how-to-guides/migrate-to-1-0-0.mdx @@ -0,0 +1,185 @@ +--- +title: Migrate to ksef2 1.0.0 +description: Upgrade pre-1.0 application code to the stable public ksef2 SDK surface. +--- + +import { Aside, LinkCard, Steps } from '@astrojs/starlight/components'; + +Use this guide when an application was built against a pre-1.0 ksef2 release and +you want to move it onto the documented 1.x public contract. + + + +## 1. Pin the upgrade window + +Install the 1.x line explicitly and run your integration tests against the same +KSeF environment your application will use: + +```bash +pip install "ksef2>=1.0,<2" +# or +uv add "ksef2>=1.0,<2" +``` + +Keep Python at 3.12 or newer. + +## 2. Move imports to public paths + +Replace imports from internal modules before changing workflow behavior. + +| Pre-1.0 or internal path | Use in 1.0 | +| --- | --- | +| `ksef2.core.xades` | `ksef2.xades` | +| `ksef2.core.exceptions` for application catches | root `ksef2` exports such as `KSeFApiError`, `KSeFRateLimitError`, and `KSeFException` | +| `ksef2.config` for common config | root `ksef2` exports such as `Environment`, `TransportConfig`, and `RetryConfig` | +| `ksef2.infra.schema.api.spec` | `from ksef2.raw import spec` | +| `ksef2.infra.schema.api.supp` | `from ksef2.raw import supp` | +| `ksef2.infra.mappers.*` | high-level SDK models, or documented bridges under `ksef2.raw.mappers` | +| `ksef2.endpoints.*` | high-level client branches or `client.raw` / `auth.raw` | +| old FA(3) builder or sample-invoice helpers | `ksef2.fa3.FA3InvoiceBuilder` and public `ksef2.fa3` models | + +If an internal import has no public replacement, move the code up to the nearest +documented workflow branch instead of copying the internal implementation. + +## 3. Rebase workflow code on authenticated branches + +The normal shape is root client, authentication, then an authenticated branch: + +```python +from ksef2 import Client, Environment, FormSchema + +client = Client(Environment.TEST) +auth = client.authentication.with_token( + ksef_token="your-ksef-token", + nip="5261040828", +) + +with auth.online_session(form_code=FormSchema.FA3) as session: + status = session.send_invoice_and_wait( + invoice_xml=b"...", + timeout=60.0, + ) + +invoice_xml = auth.invoices.wait_for_invoice_download( + ksef_number=status.ksef_number, + timeout=120.0, +) +``` + +Use `auth.invoices`, `auth.tokens`, `auth.permissions`, `auth.certificates`, and +`auth.limits` for authenticated work. Use `client.encryption`, `client.peppol`, +and TEST-only `client.testdata` for public or setup work on the root client. + +## 4. Update error handling + +Catch SDK-classified errors by public exception class and keep transport errors +separate: + +```python +import httpx + +from ksef2 import KSeFApiError, KSeFException, KSeFRateLimitError + +try: + result = auth.invoices.query_metadata(filters=filters) +except KSeFRateLimitError as exc: + retry_after = exc.retry_after +except KSeFApiError as exc: + status_code = exc.status_code + exception_code = exc.exception_code +except KSeFException as exc: + context = exc.context +except httpx.HTTPError as exc: + transport_error = exc +``` + +Polling timeout exceptions mean the local deadline expired. They do not prove +that the remote KSeF operation failed. Persist references before waiting so +another process can resume polling. + +## 5. Recheck low-level integrations + +Keep low-level code inside the supported raw surface: + +```python +from ksef2.raw import spec, supp +from ksef2.raw.mappers import auth as auth_mapper +``` + +The `ksef2.raw` import path is stable, but model fields follow the checked KSeF +OpenAPI version. When the Ministry of Finance changes the schema, raw model +shapes can change in a minor SDK release. + +## 6. Update FA(3) builder usage + +Use the public FA(3) facade for new invoice-building code: + +```python +from ksef2.fa3 import FA3InvoiceBuilder, KsefInvoiceDraft, VatRate +``` + +Persist generated XML or `KsefInvoiceDraft` state when users can pause editing. +Do not persist private builder attributes. Validate business assumptions against +the official FA(3) rules and your own accounting requirements. + +## 7. Replace old documentation links + +The public docs no longer use the old `intro`, `workflows`, or +`api-signatures` routes. + +| Old route | Current route | +| --- | --- | +| `/ksef2/sdk/intro/` | `/ksef2/sdk/getting-started/overview/` | +| `/ksef2/sdk/workflows/overview/` | `/ksef2/sdk/how-to-guides/overview/` for tasks or `/ksef2/sdk/concepts/overview/` for concepts | +| `/ksef2/sdk/reference/api-signatures/` | `/ksef2/sdk/reference/api/` | +| `/ksef2/sdk/raw/overview/` | `/ksef2/sdk/reference/low-level/overview/` | + +## Final checklist + + + +1. Search application code for `ksef2.core`, `ksef2.infra`, and + `ksef2.endpoints`. + + Replace them with documented public imports or high-level client branches. + +2. Run authentication, sending, querying, download, and administrative tests in + TEST or DEMO. + + Pay special attention to credentials, taxpayer context, permissions, and + references persisted before polling. + +3. Review logging. + + Do not log tokens, private keys, password material, raw invoice XML, or + serialized session state unless your retention policy explicitly allows it. + +4. Check the official KSeF documentation for behavior that is owned by the + Ministry of Finance. + + The SDK wraps the API; it is not the authority for legal or operational KSeF + behavior. + + + +## Related pages + + + + diff --git a/docs/en/workflows/overview.mdx b/docs/en/how-to-guides/overview.mdx similarity index 64% rename from docs/en/workflows/overview.mdx rename to docs/en/how-to-guides/overview.mdx index b4d8c91..d4adb93 100644 --- a/docs/en/workflows/overview.mdx +++ b/docs/en/how-to-guides/overview.mdx @@ -1,88 +1,115 @@ --- -title: Workflow Overview -description: Choose the right ksef2 workflow for the public SDK surface. +title: How-to Guide Overview +description: Choose the right task guide for the public ksef2 SDK surface. --- import { Aside, CardGrid, LinkCard, Steps } from '@astrojs/starlight/components'; Use this section when you already know the SDK shape and want to complete a -specific KSeF task. The quickstart shows one end-to-end path; these workflow -pages separate the decisions you usually make in production code. +specific KSeF task. The quickstart shows one end-to-end path; these how-to +guides separate the decisions you usually make in production code. + +## Setup + + + +## Invoice work + + + + - + + +## Administration + + + + +## Utilities + + @@ -127,9 +154,25 @@ pages separate the decisions you usually make in production code. -## Related guides - -- [Quickstart](../getting-started/quickstart.md) -- [Client setup](client-setup.mdx) -- [Low-level API](../raw/overview.md) -- [API reference](../reference/api-signatures.md) +## Reference + + + + + diff --git a/docs/en/how-to-guides/profiles.mdx b/docs/en/how-to-guides/profiles.mdx new file mode 100644 index 0000000..c665e27 --- /dev/null +++ b/docs/en/how-to-guides/profiles.mdx @@ -0,0 +1,276 @@ +--- +title: Use profiles +description: Share local ksef2-cli profile configuration with SDK authentication. +--- + +import { Aside, LinkCard, Steps } from '@astrojs/starlight/components'; + +Profiles are local, named authentication settings shared by `ksef2-cli` and the +SDK. Use them when the same developer workstation needs both terminal commands +and Python scripts for the same KSeF context. + +A profile stores non-secret settings: environment, NIP, authentication method, +certificate paths, polling settings, and the name of the environment variable +that contains a secret. It should not store KSeF token values, private-key +passwords, or PKCS#12 passwords. + + + +## Create a profile with the CLI + +The CLI is the most convenient way to create and inspect profile files. + +```bash +ksef2 profile create test-company \ + --env test \ + --nip 5261040828 \ + --test-cert + +ksef2 profile current +ksef2 profile show test-company +``` + +Token and certificate profiles store the environment variable name that holds +the secret, not the secret value: + +```bash +export KSEF2_TOKEN=replace-with-a-real-ksef-token + +ksef2 profile create prod-token \ + --env production \ + --nip 5261040828 \ + --token-env KSEF2_TOKEN +``` + + + + + +## Authenticate with a profile + +`with_profile()` reads the same profile file as the CLI. Passing no name uses +the currently selected profile. + +```python +from ksef2 import Client, Environment + +with Client(Environment.TEST) as client: + auth = client.authentication.with_profile() +``` + +Pass a name to ignore the active profile for one call: + +```python +from ksef2 import Client, Environment + +with Client(Environment.TEST) as client: + auth = client.authentication.with_profile("test-company") +``` + +The root client environment must match the selected profile environment. A +`test` profile must be used with `Client(Environment.TEST)`, a `demo` profile +with `Client(Environment.DEMO)`, and a `production` profile with +`Client(Environment.PRODUCTION)`. + +If you want the profile to choose the client environment, load it first: + +```python +from ksef2 import Client +from ksef2.profiles import load_cli_profile + +profile_name, profile = load_cli_profile("prod-token") + +with Client(profile.sdk_environment) as client: + auth = client.authentication.with_profile(profile_name) +``` + +Async clients use the same profile file and selection order: + +```python +from ksef2 import AsyncClient, Environment + +async with AsyncClient(Environment.TEST) as client: + auth = await client.authentication.with_profile("test-company") +``` + +## Profile selection order + +The SDK selects a profile in the same order as `ksef2-cli`: + +1. The explicit name passed to `with_profile("name")`. +2. `KSEF2_PROFILE` in the process environment. +3. `active_profile` in the local profile config file. + +By default, the profile file is: + +```text +~/.config/ksef2-cli/config.toml +``` + +Set `KSEF2_CONFIG` or pass `config_path` when the profile file lives somewhere +else: + +```python +from ksef2 import Client, Environment + +with Client(Environment.PRODUCTION) as client: + auth = client.authentication.with_profile( + "prod-token", + config_path="./local.ksef2.toml", + ) +``` + +## Manage profiles from SDK code + +Use `ProfileStore` when a Python tool should create, update, select, or inspect +the same profile file used by the CLI. Use `with_profile()` when you only need +to authenticate. + +```python +from ksef2 import Environment +from ksef2.profiles import Profile, ProfileStore, TokenProfileAuth + +store = ProfileStore.default() +store.save( + "prod-token", + Profile( + environment=Environment.PRODUCTION, + nip="5261040828", + auth=TokenProfileAuth( + token_env="KSEF2_TOKEN", + context_type="nip", + ), + poll_interval=2.0, + max_poll_attempts=90, + ), + activate=True, + overwrite=True, +) +``` + +For a TEST certificate profile: + +```python +from ksef2 import Environment +from ksef2.profiles import Profile, ProfileStore, TestCertificateProfileAuth + +store = ProfileStore.default() +store.save( + "test-company", + Profile( + environment=Environment.TEST, + nip="5261040828", + auth=TestCertificateProfileAuth(), + ), + activate=True, + overwrite=True, +) +``` + +You can perform basic profile management without the CLI, but the CLI is usually more convenient. + +```python +from ksef2.profiles import ProfileStore + +store = ProfileStore.default() + +profiles = store.list() +current = store.current() +profile = store.get("prod-token") +store.use("prod-token") +store.delete("old-profile") +``` + +`current` is either `None` or a `(name, profile)` tuple. + +## What the config contains + +The rendered file uses the same field names as the public SDK profile models: + +```toml +active_profile = "prod-token" + +[profiles.prod-token] +environment = "production" +nip = "5261040828" +poll_interval = 2.0 +max_poll_attempts = 90 + +[profiles.prod-token.auth] +type = "token" +token_env = "KSEF2_TOKEN" +context_type = "nip" +``` + +For XAdES profiles, the profile stores paths and password environment variable +names: + +```python +from ksef2 import Environment +from ksef2.profiles import Profile, ProfileStore, XadesP12ProfileAuth + +store = ProfileStore.default() +store.save( + "prod-p12", + Profile( + environment=Environment.PRODUCTION, + nip="5261040828", + auth=XadesP12ProfileAuth( + p12="signing-credentials.p12", + p12_password_env="KSEF2_P12_PASSWORD", + ), + ), + activate=False, + overwrite=True, +) +``` + +## Recommended flow + + + +1. Put secret values in environment variables. + + Examples: `KSEF2_TOKEN`, `KSEF2_KEY_PASSWORD`, and + `KSEF2_P12_PASSWORD`. + +2. Create or select the profile with `ksef2-cli`. + + This keeps terminal workflows and Python scripts aligned. + +3. Create the SDK root client for the same environment as the profile. + + The SDK validates this before authenticating. + +4. Call `client.authentication.with_profile()`. + + The returned authenticated client is used like any other authenticated SDK + client. + + + +## Related pages + + + + diff --git a/docs/en/how-to-guides/query-invoices.mdx b/docs/en/how-to-guides/query-invoices.mdx new file mode 100644 index 0000000..7c071da --- /dev/null +++ b/docs/en/how-to-guides/query-invoices.mdx @@ -0,0 +1,207 @@ +--- +title: Query Invoices +description: Query invoice metadata with ksef2 filters, pagination helpers, and polling. +--- + +import { Aside, LinkCard, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; + +Use `auth.invoices` when you need invoice metadata outside a sending session. +The examples below assume you already have an authenticated client named `auth`. + +## Build a narrow filter + +Start with the business question: which role, which date field, and which time +window should KSeF search? + +```python +from datetime import datetime, timedelta, timezone + +from ksef2.models import InvoicesFilter + +now = datetime.now(tz=timezone.utc) + +filters = InvoicesFilter.for_seller( + date_from=now - timedelta(days=7), + date_to=now, + invoice_types=["vat"], + invoicing_mode="online", +) +``` + + + +## Query one page + +Use `query_metadata()` when you need one page for a screen, reconciliation step, +or diagnostic check. + +```python +from ksef2.models import InvoiceMetadataParams + +page = auth.invoices.query_metadata( + filters=filters, + params=InvoiceMetadataParams(page_size=25, sort_order="asc"), +) + +# QueryInvoicesMetadataResponse +# { +# "has_more": false, +# "is_truncated": false, +# "permanent_storage_hwm_date": null, +# "invoices": [ +# { +# "ksef_number": "1234567890-20260625-...", +# "invoice_number": "FV/42/2026", +# "issue_date": "2026-06-25", +# "gross_amount": 1230.0, +# "currency": "PLN", +# "invoice_type": "vat" +# } +# ] +# } + +for invoice in page.invoices: + print(invoice.ksef_number, invoice.invoice_number) +``` + +`has_more` means another page exists. `is_truncated` means the result hit a KSeF +window boundary and should continue from the returned lifecycle date rather than +from a normal page offset. + +## Iterate through results + +Use the page iterator when each page matters. Use the item iterator when your +job only needs invoice rows. + + + + +```python +params = InvoiceMetadataParams(page_size=100, sort_order="asc") + +for page in auth.invoices.query_metadata_pages(filters=filters, params=params): + print(f"page={len(page.invoices)} has_more={page.has_more}") + + for invoice in page.invoices: + print(invoice.ksef_number, invoice.permanent_storage_date) +``` + + + + +```python +params = InvoiceMetadataParams(page_size=100, sort_order="asc") + +for invoice in auth.invoices.all_metadata(filters=filters, params=params): + print(invoice.ksef_number, invoice.invoice_number) +``` + + + + +## Find a specific invoice + +If you know your own invoice number, include it in the filter. If you already +have a KSeF number, prefer filtering by `ksef_number` or download the invoice +directly. + +```python +filters = InvoicesFilter.for_seller( + date_from=now - timedelta(days=30), + date_to=now, + invoice_number="FV/42/2026", +) + +page = auth.invoices.query_metadata(filters=filters) +``` + +```python +filters = InvoicesFilter.for_seller( + date_type="permanent_storage", + date_from=now - timedelta(days=30), + date_to=now, + ksef_number="1234567890-20260625-...", +) + +page = auth.invoices.query_metadata(filters=filters) +``` + +## Wait after sending + +KSeF processing is asynchronous. If a workflow sends an invoice and immediately +needs it to become visible in retrieval APIs, poll with a narrow filter. + +```python +filters = InvoicesFilter.for_seller( + date_from=now - timedelta(days=1), + date_to=now, + invoice_number="FV/42/2026", +) + +result = auth.invoices.wait_for_invoices( + filters=filters, + timeout=120.0, + poll_interval=2.0, +) + +# QueryInvoicesMetadataResponse +# { +# "has_more": false, +# "is_truncated": false, +# "permanent_storage_hwm_date": null, +# "invoices": [ +# { +# "ksef_number": "1234567890-20260625-...", +# "invoice_number": "FV/42/2026" +# } +# ] +# } +``` + + + +## Recommended flow + + + +1. Choose the subject role you are querying as. + +2. Pick the date field that matches the job: accounting view, processing view, + or incremental sync. + +3. Build a narrow `InvoicesFilter`. + +4. Fetch one page for interactive work, or use iterators for background jobs. + +5. Store `ksef_number` values for later direct downloads or export + reconciliation. + + + +## Next workflows + + + + + + diff --git a/docs/en/how-to-guides/query-peppol-providers.mdx b/docs/en/how-to-guides/query-peppol-providers.mdx new file mode 100644 index 0000000..9cd849c --- /dev/null +++ b/docs/en/how-to-guides/query-peppol-providers.mdx @@ -0,0 +1,85 @@ +--- +title: Query PEPPOL Providers +description: Query PEPPOL service providers registered in KSeF. +--- + +import { LinkCard, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; + +Use `client.peppol` when you need the public list of PEPPOL service providers +registered in KSeF. This is a root-client branch and does not require +authentication. + +## Query providers + + + + +```python +page = client.peppol.query() + +# ListPeppolProvidersResponse +# { +# "has_more": false, +# "providers": [ +# { +# "id": "PPL123456", +# "name": "Example PEPPOL Provider", +# "date_created": "2026-06-25T10:00:00Z" +# } +# ] +# } + +for provider in page.providers: + print(provider.id, provider.name) +``` + + + + +```python +for provider in client.peppol.all(): + print(provider.id, provider.name, provider.date_created) +``` + + + + +## Cache provider choices + +Provider records are reference data. Query them from KSeF, then cache the id and +display name for user selection or validation in your product. + +```python +providers_by_id = { + provider.id: provider.name + for provider in client.peppol.all() +} +``` + +## Recommended flow + + + +1. Query providers from the root client. + +2. Store provider ids and names in your application's reference-data cache. + +3. Use provider ids when validating user choices or PEPPOL-related data. + +4. Refresh the cache according to your product's data freshness needs. + + + +## Next workflows + + + + diff --git a/docs/en/how-to-guides/send-invoices.mdx b/docs/en/how-to-guides/send-invoices.mdx new file mode 100644 index 0000000..df0bc00 --- /dev/null +++ b/docs/en/how-to-guides/send-invoices.mdx @@ -0,0 +1,273 @@ +--- +title: Send Invoices +description: Send FA(3) XML through ksef2 online sessions or batch sessions. +--- + +import { Aside, LinkCard, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; + +Use `auth.online_session(...)` for interactive submission and `auth.batch` for +larger file sets. The examples below assume you already have an authenticated +client named `auth`. + +## Start with XML bytes + +The send APIs accept invoice XML bytes. Those bytes can come from your ERP, a +file, or the SDK's FA(3) builder. + + + + +```python +from pathlib import Path + +invoice_xml = Path("invoice.xml").read_bytes() +``` + + + + +```python +from datetime import date +from decimal import Decimal + +from ksef2.fa3 import FA3InvoiceBuilder, VatRate + +invoice_xml = ( + FA3InvoiceBuilder() + .header(system_info="ksef2 send guide") + .seller( + name="Demo Seller Sp. z o.o.", + tax_id="5261040828", + country_code="PL", + address_line_1="Prosta 1", + address_line_2="00-001 Warszawa", + ) + .buyer( + name="Demo Buyer Sp. z o.o.", + country_code="PL", + address_line_1="Kwiatowa 2", + address_line_2="00-002 Warszawa", + ) + .standard() + .issue_date(date.today()) + .issue_place("Warszawa") + .invoice_number("FV/42/2026") + .rows() + .add_line( + name="Consulting service", + quantity=Decimal("1"), + unit_price_net=Decimal("100.00"), + vat_rate=VatRate.VAT_23, + ) + .done() + .done() + .to_xml() + .encode("utf-8") +) +``` + + + + +## Send in an online session + +Use an online session when you want to submit one invoice or a small interactive +set. The session context manager closes the remote online session when the block +exits. + +```python +from ksef2 import FormSchema + +with auth.online_session(form_code=FormSchema.FA3) as session: + sent = session.send_invoice(invoice_xml=invoice_xml) + + # SendInvoiceResponse + # { + # "reference_number": "20260625-ABCD-EF1234567890" + # } + + status = session.wait_for_invoice_ready( + invoice_reference_number=sent.reference_number, + timeout=120.0, + poll_interval=2.0, + ) + + # SessionInvoiceStatusResponse + # { + # "reference_number": "20260625-ABCD-EF1234567890", + # "ksef_number": "1234567890-20260625-...", + # "invoice_number": "FV/42/2026", + # "status": { + # "code": 200, + # "description": "Processed" + # } + # } +``` + +`send_invoice()` only means KSeF accepted the encrypted payload into the session. +`wait_for_invoice_ready()` waits for the per-invoice result and returns the KSeF +number when processing succeeds. + + + +## Keep session handles + +Persist the handles before long polling or before handing work to another +process. + +```python +with auth.online_session(form_code=FormSchema.FA3) as session: + session_state_json = session.resume_state().to_json() + sent = session.send_invoice(invoice_xml=invoice_xml) + invoice_reference_number = sent.reference_number + +# Store session_state_json and invoice_reference_number in secure storage. +# Do not log session_state_json because it contains session encryption data. +``` + +## Send a batch + +Use a batch when you need to submit many XML files as one KSeF batch workflow. +The high-level batch service prepares the ZIP package, encrypts package parts, +opens the batch session, uploads the parts, closes the session, and returns a +`BatchSessionResumeState`. + + + + +```python +from pathlib import Path + +from ksef2 import FormSchema + +prepared = auth.batch.prepare_batch_from_paths( + invoice_paths=[ + Path("invoice-1.xml"), + Path("invoice-2.xml"), + ], + form_code=FormSchema.FA3, +) + +state = auth.batch.submit_prepared_batch(prepared_batch=prepared) + +# BatchSessionResumeState(reference_number="20260625-BATCH-...") +# Persist the state securely. It contains encryption material and upload URLs. +``` + + + + +```python +from pathlib import Path + +from ksef2 import FormSchema +from ksef2.models import BatchInvoice + +state = auth.batch.submit_batch( + invoices=[ + BatchInvoice( + file_name="invoice-1.xml", + content=Path("invoice-1.xml").read_bytes(), + ), + BatchInvoice( + file_name="invoice-2.xml", + content=Path("invoice-2.xml").read_bytes(), + ), + ], + form_code=FormSchema.FA3, +) + +# BatchSessionResumeState(reference_number="20260625-BATCH-...") +# Persist the state securely. It contains encryption material and upload URLs. +``` + + + + +## Wait for batch completion + +After the batch is submitted, poll the batch session and inspect accepted and +failed invoices. + +```python +final_status = auth.batch.wait_for_completion( + session=state, + timeout=300.0, + poll_interval=2.0, +) + +# SessionStatusResponse +# { +# "status": { +# "code": 200, +# "description": "Processed" +# }, +# "invoice_count": 2, +# "successful_invoice_count": 2, +# "failed_invoice_count": 0, +# "upo": { +# "pages": [ +# { +# "reference_number": "upo-page-reference" +# } +# ] +# } +# } + +accepted = auth.batch.list_invoices(session=state, page_size=100) +failed = auth.batch.list_failed_invoices(session=state, page_size=100) +``` + + + +## Recommended flow + + + +1. Authenticate for the seller context. + +2. Load invoice XML from your system or build it with `FA3InvoiceBuilder`. + +3. Use an online session for small interactive sending or `auth.batch` for + larger sets. + +4. Persist the returned session and invoice references before polling. + +5. Poll status, store accepted and failed results, then download UPO or invoice + XML from the follow-up pages. + + + +## Next workflows + + + + + + + + diff --git a/docs/en/how-to-guides/use-test-data.mdx b/docs/en/how-to-guides/use-test-data.mdx new file mode 100644 index 0000000..f520ac3 --- /dev/null +++ b/docs/en/how-to-guides/use-test-data.mdx @@ -0,0 +1,149 @@ +--- +title: Use TEST Data +description: Create sandbox subjects, people, permissions, attachment flags, and blocked contexts in TEST. +--- + +import { Aside, LinkCard, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; + +Use `client.testdata` only with `Environment.TEST`. These helpers mutate KSeF +sandbox data so tests and demos can create known contexts. + +## Create sandbox data manually + +Use direct methods for shared fixtures that should survive multiple test runs. + + + + +```python +client.testdata.create_subject( + nip="5261040828", + subject_type="vat_group", + description="Sandbox company", +) + +client.testdata.enable_attachments(nip="5261040828") +``` + + + + +```python +client.testdata.create_person( + nip="5261040828", + pesel="90010112345", + description="Sandbox person", +) +``` + + + + +```python +from ksef2.models import AuthContextIdentifier + +context = AuthContextIdentifier(type="nip", value="5261040828") + +client.testdata.block_context(context=context) +client.testdata.unblock_context(context=context) +``` + + + + +Delete shared fixtures explicitly when they are no longer needed: + +```python +client.testdata.delete_person(nip="5261040828") +client.testdata.delete_subject(nip="5261040828") +``` + +## Use temporal cleanup + +`temporal()` records mutations and attempts best-effort cleanup when the block +exits. + + + + +```python +with client.testdata.temporal() as data: + data.create_subject( + nip="5261040828", + subject_type="vat_group", + description="Integration test subject", + ) + data.enable_attachments(nip="5261040828") +``` + + + + +```python +from ksef2.models import Identifier, Permission + +with client.testdata.temporal() as data: + data.grant_permissions( + permissions=[ + Permission(type="invoice_read", description="Read invoices"), + ], + grant_to=Identifier(type="nip", value="1111111111"), + in_context_of=Identifier(type="nip", value="5261040828"), + ) +``` + + + + +```python +from ksef2.models import AuthContextIdentifier + +context = AuthContextIdentifier(type="nip", value="5261040828") + +with client.testdata.temporal() as data: + data.block_context(context=context) +``` + + + + + + +## Recommended flow + + + +1. Create only the subjects, people, permissions, attachment flags, or blocked + contexts required by the test. + +2. Use `temporal()` for fixtures that should be cleaned up automatically. + +3. Use direct create/delete methods for fixtures shared across many test runs. + +4. Keep generated identifiers in test configuration, not production + configuration. + + + +## Next workflows + + + + + + diff --git a/docs/en/how-to-guides/use-xades-helpers.mdx b/docs/en/how-to-guides/use-xades-helpers.mdx new file mode 100644 index 0000000..7a7cc4b --- /dev/null +++ b/docs/en/how-to-guides/use-xades-helpers.mdx @@ -0,0 +1,147 @@ +--- +title: Use XAdES Helpers +description: Load certificates and private keys, generate TEST certificates, and sign XML for KSeF authentication. +--- + +import { Aside, LinkCard, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; + +Use XAdES helpers when you authenticate with certificate material or need to +diagnose the signed authentication XML. Most applications should still call +`client.authentication.with_xades()` instead of signing manually. + +## Load certificate material + + + + +```python +import os + +from ksef2.xades import load_certificate_from_pem, load_private_key_from_pem + +password = os.environ.get("KSEF2_KEY_PASSWORD") + +cert = load_certificate_from_pem("company.pem") +private_key = load_private_key_from_pem( + "company.key", + password=password.encode() if password else None, +) +``` + + + + +```python +import os + +from ksef2.xades import load_certificate_and_key_from_p12 + +password = os.environ.get("KSEF2_P12_PASSWORD") + +cert, private_key = load_certificate_and_key_from_p12( + "company.p12", + password=password.encode() if password else None, +) +``` + + + + +## Authenticate with XAdES + +Pass the loaded certificate and private key to the authentication branch. + +```python +auth = client.authentication.with_xades( + nip="5261040828", + cert=cert, + private_key=private_key, +) +``` + +## Generate TEST certificate material + +Use generated certificates only in TEST workflows. + + + + +```python +from ksef2.xades import generate_test_certificate + +cert, private_key = generate_test_certificate(nip="5261040828") +``` + + + + +```python +from ksef2.xades import generate_personal_test_certificate + +cert, private_key = generate_personal_test_certificate( + pesel="90010112345", + nip="5261040828", +) +``` + + + + +## Sign XML directly + +Use direct signing helpers for diagnostics or lower-level tests. For normal +authentication, prefer `with_xades()`. + +```python +from ksef2.xades import build_auth_token_request_xml, sign_xades + +xml = build_auth_token_request_xml( + challenge="challenge-from-ksef", + nip="5261040828", +) + +signed_xml = sign_xades(xml, cert, private_key) +``` + + + +## Recommended flow + + + +1. Load certificate material from PEM or PKCS#12. + +2. Keep private-key passwords in environment variables or a secret manager. + +3. Authenticate through `with_xades()` when possible. + +4. Generate self-signed certificates only for TEST. + +5. Use direct signing helpers only for diagnostics or low-level integration + tests. + + + +## Next workflows + + + + + + diff --git a/docs/en/intro.md b/docs/en/intro.md deleted file mode 100644 index 0296edf..0000000 --- a/docs/en/intro.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -title: Overview -description: Start here for the ksef2 Python SDK documentation. ---- - -ksef2 is a Python SDK for Poland's KSeF v2 API. It gives you sync and async -clients, typed request models, invoice session helpers, and low-level endpoint -access. - -> **Unofficial SDK.** ksef2 is a community-maintained Python SDK. It is not -> published, endorsed, or supported by Poland's Ministry of Finance. Use the -> official KSeF documentation as the authority for API behavior. - -## Start - -- [Run the quickstart](getting-started/quickstart.md) -- [Choose an authentication workflow](workflows/authentication.mdx) -- [Send, query, and download invoices](workflows/overview.mdx) - -## Main pages - -- [Workflow overview](workflows/overview.mdx): task-oriented paths for - clients, authentication, invoices, status, tokens, permissions, certificates, - limits, public lookup, TEST data, and XAdES. -- [Client setup](workflows/client-setup.mdx): choose sync or async, public root - branches, and authenticated workflow branches. -- [Public API contract](guides/public-api.md): stable import paths and internal - package boundaries for application code. -- [Error handling](guides/errors.md): catch SDK exceptions, inspect KSeF error - payloads, and handle polling timeouts. -- [Sending invoices](workflows/sending-invoices.mdx): open online or batch - sessions and submit XML to KSeF. -- [Admin workflows](workflows/tokens.mdx): start with tokens, then use - permissions, certificates, and limits as needed. -- [Low-level API](raw/overview.md): use schema-native endpoint wrappers for - custom signing, encryption custody, or exact KSeF payload debugging. - -## Reference - -Use the [API reference](reference/api-signatures.md) when you need signatures, -return types, model names, or the exact generated sync/async variants. diff --git a/docs/en/raw/authentication.md b/docs/en/raw/authentication.md deleted file mode 100644 index 35c1427..0000000 --- a/docs/en/raw/authentication.md +++ /dev/null @@ -1,106 +0,0 @@ ---- -title: Low-level authentication -description: Run KSeF authentication steps manually and bind the result back to ksef2 clients. ---- - -Use low-level authentication when another system owns signing, token encryption, -or polling policy. The SDK still sends requests and parses responses, but your -code chooses each step. - -## Token authentication - -```python -from time import sleep - -from ksef2 import Client, Environment -from ksef2.raw import encrypt_token, spec -from ksef2.raw.mappers import auth as auth_mapper - -POLL_INTERVAL_SECONDS = 1.0 -MAX_POLL_ATTEMPTS = 60 - -client = Client(Environment.TEST) - -challenge = client.raw.auth.challenge() -cert = next( - cert - for cert in client.raw.encryption.fetch_public_certificates() - if spec.PublicKeyCertificateUsage.KsefTokenEncryption in cert.usage -) - -encrypted = encrypt_token( - "your-ksef-token", - str(challenge.timestampMs), - cert.certificate, -) -request = spec.InitTokenAuthenticationRequest( - challenge=challenge.challenge, - contextIdentifier=spec.AuthenticationContextIdentifier( - type=spec.AuthenticationContextIdentifierType.Nip, - value="5261040828", - ), - encryptedToken=encrypted, - publicKeyId=cert.publicKeyId, -) - -init = client.raw.auth.token_auth(request) - -for _ in range(MAX_POLL_ATTEMPTS): - status = client.raw.auth.auth_status( - bearer_token=init.authenticationToken.token, - reference_number=init.referenceNumber, - ) - - if status.status.code == 200: - break - if status.status.code >= 400: - raise RuntimeError( - f"Authentication failed: {status.status.code} " - f"{status.status.description}" - ) - - sleep(POLL_INTERVAL_SECONDS) -else: - raise TimeoutError("Authentication did not finish") - -raw_tokens = client.raw.auth.redeem_token(init.authenticationToken.token) -auth = client.authenticated(auth_mapper.from_spec(raw_tokens)) -``` - -After `client.authenticated(...)`, you can use either workflow branches such as -`auth.invoices` or low-level branches such as `auth.raw.invoices`. - -## XAdES with external signing - -Use this shape when a signing gateway or HSM returns the signed XML. - -```python -from ksef2.raw.mappers import auth as auth_mapper - -challenge = client.raw.auth.challenge() -signed_xml = signing_gateway.sign_ksef_challenge( - challenge=challenge.challenge, - nip="5261040828", -) - -init = client.raw.auth.xades_auth(signed_xml, verify_chain=True) -status = client.raw.auth.auth_status( - bearer_token=init.authenticationToken.token, - reference_number=init.referenceNumber, -) - -if status.status.code == 200: - raw_tokens = client.raw.auth.redeem_token(init.authenticationToken.token) - auth = client.authenticated(auth_mapper.from_spec(raw_tokens)) -``` - -## Async shape - -Async clients expose the same low-level branches. Await network calls: - -```python -challenge = await client.raw.auth.challenge() -init = await client.raw.auth.xades_auth(signed_xml) -raw_tokens = await client.raw.auth.redeem_token(init.authenticationToken.token) -auth = client.authenticated(auth_mapper.from_spec(raw_tokens)) -``` diff --git a/docs/en/raw/endpoint-map.md b/docs/en/raw/endpoint-map.md deleted file mode 100644 index af2d4c1..0000000 --- a/docs/en/raw/endpoint-map.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -title: Low-level endpoint map -description: Find the low-level ksef2 endpoint group for each KSeF area. ---- - -Low-level endpoint groups are thin facades over the SDK endpoint wrappers. They -are exposed through `client.raw` and `auth.raw`, use schema-native request and -response models, and share the same transport stack as the workflow clients. - -## Before authentication - -| Low-level branch | Use for | -| --- | --- | -| `client.raw.auth` | Challenge creation, token auth, XAdES auth, auth status, token redemption. | -| `client.raw.encryption` | Public KSeF encryption certificates. | -| `client.raw.peppol` | Public PEPPOL provider lookup. | -| `client.raw.testdata` | TEST-only subject, person, permission, attachment, and context fixtures. | - -## After authentication - -| Low-level branch | Use for | -| --- | --- | -| `auth.raw.auth` | Auth session listing and termination. | -| `auth.raw.certificates` | Certificate limits, enrollment, retrieval, query, and revocation. | -| `auth.raw.encryption` | Public KSeF encryption certificates. | -| `auth.raw.invoices` | Invoice metadata, export, download, online send, session invoice status, and UPO. | -| `auth.raw.limits` | Context, subject, and API rate-limit endpoints. | -| `auth.raw.peppol` | Public PEPPOL provider lookup. | -| `auth.raw.permissions.grant` | Permission grant endpoints. | -| `auth.raw.permissions.revoke` | Permission revocation endpoints. | -| `auth.raw.permissions.query` | Permission search and attachment status endpoints. | -| `auth.raw.permissions.status` | Permission operation status and entity role endpoints. | -| `auth.raw.session` | Online session open/terminate, batch session open/close, session UPO, session listing. | -| `auth.raw.testdata` | TEST-only fixture endpoints. | -| `auth.raw.tokens` | Token generation, listing, status, and revocation. | - -## Imports - -```python -from ksef2.raw import ( - encrypt_invoice, - encrypt_symmetric_key, - encrypt_token, - generate_session_key, - prepare_batch_package, - sha256_b64, - spec, - supp, -) -from ksef2.raw.mappers import auth as auth_mapper -``` - -Low-level utility exports stay focused on KSeF mechanics such as encryption and -hashing. Request bodies stay explicit through `spec.*` models. Public mappers -such as `auth_mapper.from_spec(...)` are the explicit bridge from low-level -response models back to SDK domain models. diff --git a/docs/en/raw/overview.md b/docs/en/raw/overview.md deleted file mode 100644 index 1e050f6..0000000 --- a/docs/en/raw/overview.md +++ /dev/null @@ -1,77 +0,0 @@ ---- -title: Low-level API -description: Use schema-native endpoint calls when a KSeF integration needs lower-level control. ---- - -The low-level API is the SDK's advanced seam for callers who need endpoint-level -control without leaving the SDK transport. It is exposed through the `raw` -client branch. Use it when you need custom signing, custom encryption custody, -exact OpenAPI-shaped payloads, or a debugging path that shows what KSeF -receives. - -Most application code should still start with the workflow clients. Low-level -calls are intentionally explicit: request and response objects use KSeF/OpenAPI -field names such as `referenceNumber`, `publicKeyId`, and -`authenticationToken`. - -## Three levels - -```python -# Workflow level: SDK owns the whole task. -status = session.send_invoice_and_wait(invoice_xml=invoice_xml) - -# Step level: SDK owns protocol details, caller owns ordering. -result = session.send_invoice(invoice_xml=invoice_xml) -status = session.wait_for_invoice_ready( - invoice_reference_number=result.reference_number, -) - -# Low-level API: caller owns endpoint order and schema-native payloads. -sent = auth.raw.invoices.send(reference_number, send_request) -``` - -The low-level API is still higher than `httpx`: it keeps SDK retry handling, -lifecycle checks, bearer-token middleware, response parsing, and KSeF exception -mapping. - -## Import models - -Import schema-native models from `ksef2.raw`, not from the internal `infra` -package. - -```python -from ksef2.raw import spec - -request = spec.GenerateTokenRequest(...) -response = auth.raw.tokens.generate_token(request) -``` - -Some low-level endpoint methods use supplemental SDK schema models where the -generated OpenAPI model is not Python-friendly. Those models are re-exported -from `ksef2.raw.spec` for the common path, and `ksef2.raw.supp` is available -when you need the supplemental package directly. - -## Mix low-level and workflow calls - -You can move between levels. A common pattern is manual low-level -authentication, then normal workflow calls: - -```python -from ksef2.raw.mappers import auth as auth_mapper - -raw_tokens = client.raw.auth.redeem_token(auth_token) -auth = client.authenticated(auth_mapper.from_spec(raw_tokens)) - -with auth.online_session(form_code=FormSchema.FA3) as session: - status = session.send_invoice_and_wait(invoice_xml=invoice_xml) -``` - -The main rule is session ownership. If low-level code opens a session, low-level -code should usually close and poll that session. If high-level code opens a -session, use the session client returned by the SDK. - -## Low-level API section - -- [Manual authentication](authentication.md) -- [Sessions and invoices](sessions-invoices.md) -- [Endpoint map](endpoint-map.md) diff --git a/docs/en/raw/sessions-invoices.md b/docs/en/raw/sessions-invoices.md deleted file mode 100644 index bcf89ae..0000000 --- a/docs/en/raw/sessions-invoices.md +++ /dev/null @@ -1,109 +0,0 @@ ---- -title: Low-level sessions and invoices -description: Open KSeF sessions and submit invoice payloads through schema-native low-level endpoints. ---- - -Use low-level session and invoice calls when your integration needs to own -encryption material, session opening, invoice submission, or polling order. - -## Open an online session - -```python -import base64 - -from ksef2.raw import ( - encrypt_symmetric_key, - generate_session_key, - spec, -) - -cert = next( - cert - for cert in auth.raw.encryption.fetch_public_certificates() - if spec.PublicKeyCertificateUsage.SymmetricKeyEncryption in cert.usage -) -aes_key, iv = generate_session_key() -encrypted_key = encrypt_symmetric_key(aes_key, cert.certificate) - -opened = auth.raw.session.open_online( - spec.OpenOnlineSessionRequest( - formCode=spec.FormCode( - systemCode="FA (3)", - schemaVersion="1-0E", - value="FA", - ), - encryption=spec.EncryptionInfo( - encryptedSymmetricKey=base64.b64encode(encrypted_key).decode(), - initializationVector=base64.b64encode(iv).decode(), - publicKeyId=cert.publicKeyId, - ), - ) -) -``` - -Keep `aes_key`, `iv`, and `opened.referenceNumber`; low-level invoice submission -needs all three. - -## Send one invoice - -```python -import base64 - -from ksef2.raw import encrypt_invoice, sha256_b64, spec - -encrypted = encrypt_invoice(xml_bytes=invoice_xml, key=aes_key, iv=iv) -request = spec.SendInvoiceRequest( - invoiceHash=sha256_b64(invoice_xml), - invoiceSize=len(invoice_xml), - encryptedInvoiceHash=sha256_b64(encrypted), - encryptedInvoiceSize=len(encrypted), - encryptedInvoiceContent=base64.b64encode(encrypted).decode(), -) - -sent = auth.raw.invoices.send(opened.referenceNumber, request) -status = auth.raw.invoices.get_session_invoice_status( - opened.referenceNumber, - sent.referenceNumber, -) -``` - -Low-level calls do not poll for you. Poll `get_session_invoice_status()` until -the response contains a KSeF number or reaches a failed terminal status. - -## Schedule an export with caller-owned encryption - -```python -import base64 -from datetime import datetime, timezone - -from ksef2.raw import spec - -request = spec.InvoiceExportRequest( - encryption=spec.EncryptionInfo( - encryptedSymmetricKey=base64.b64encode(encrypted_key).decode(), - initializationVector=base64.b64encode(iv).decode(), - publicKeyId=cert.publicKeyId, - ), - filters=spec.InvoiceQueryFilters( - subjectType=spec.InvoiceQuerySubjectType.Subject1, - dateRange=spec.InvoiceQueryDateRange( - dateType=spec.InvoiceQueryDateType.Issue, - **{"from": datetime(2026, 1, 1, tzinfo=timezone.utc)}, - ), - ), - compressionType=spec.CompressionType.Zip, -) - -export = auth.raw.invoices.export(request) -package = auth.raw.invoices.get_export_status(export.referenceNumber) -``` - -Use the higher-level `auth.invoices.fetch_package_bytes(...)` only when you also -have the `aes_key` and `iv` required to decrypt the package. - -## Batch preparation - -`ksef2.raw.prepare_batch_package` re-exports the lower-level batch package -builder. It lets you provide `aes_key`, `iv`, `encrypted_key`, and -`public_key_id`, then open the batch session with `auth.raw.session.open_batch()` -or the step-level `auth.open_batch_session(...)`. diff --git a/docs/en/reference/client-lifecycle.mdx b/docs/en/reference/client-lifecycle.mdx new file mode 100644 index 0000000..3288054 --- /dev/null +++ b/docs/en/reference/client-lifecycle.mdx @@ -0,0 +1,140 @@ +--- +title: Client Lifecycle Reference +description: Constructor arguments, branch ownership, close behavior, and lifecycle errors for ksef2 clients. +--- + +import { LinkCard } from '@astrojs/starlight/components'; + +This page is the reference contract for root clients and authenticated clients. +Use the how-to guide when you need a runnable setup sequence. + +## Root client constructors + +```python +Client( + environment=Environment.PRODUCTION, + *, + transport_config=None, + http_client=None, +) + +AsyncClient( + environment=Environment.PRODUCTION, + *, + transport_config=None, + http_client=None, +) +``` + +| Argument | Sync type | Async type | Default | Meaning | +| --- | --- | --- | --- | --- | +| `environment` | `Environment` | `Environment` | `Environment.PRODUCTION` | KSeF environment and base URL used by the root client. | +| `transport_config` | `TransportConfig | None` | `TransportConfig | None` | SDK-managed HTTP timeout, pool, TLS, proxy, HTTP/2, and retry settings. | +| `http_client` | `httpx.Client | None` | `httpx.AsyncClient | None` | Caller-supplied HTTP client. When provided, the caller owns its HTTP settings and final close. | + +When `http_client` is omitted, the SDK creates the underlying `httpx` client +from `transport_config` and closes it when the root client closes. + +## Root client members + +| Member | Available on | Returns | Notes | +| --- | --- | --- | --- | +| `authentication` | `Client`, `AsyncClient` | `AuthClient` / `AsyncAuthClient` | Entry point for token, XAdES, TEST certificate, and profile authentication. | +| `encryption` | `Client`, `AsyncClient` | `EncryptionClient` / `AsyncEncryptionClient` | Public KSeF encryption-certificate lookup. | +| `peppol` | `Client`, `AsyncClient` | `PeppolClient` / `AsyncPeppolClient` | Public PEPPOL provider lookup. | +| `testdata` | `Client`, `AsyncClient` | `TestDataClient` / `AsyncTestDataClient` | TEST-only fixture branch. Raises outside `Environment.TEST`. | +| `raw` | `Client`, `AsyncClient` | `RawClient` / `AsyncRawClient` | Low-level unauthenticated endpoint groups. | +| `authenticated(auth_tokens)` | `Client`, `AsyncClient` | `AuthenticatedClient` / `AsyncAuthenticatedClient` | Deprecated compatibility wrapper. Use `authentication.resume(AuthenticationResumeState.from_tokens(auth_tokens))`. | +| `close()` | `Client` | `None` | Idempotently closes an SDK-owned sync HTTP client and invalidates cached branches. | +| `aclose()` | `AsyncClient` | `None` | Idempotently closes async resources and invalidates cached branches. | + +Branch properties are cached while the root client is open. After the root +client closes, branch access and operations guarded by lifecycle middleware +raise `KSeFClientClosedError`. + +## Context managers + +| Client | Context protocol | Cleanup method | +| --- | --- | --- | +| `Client` | `with Client(...) as client:` | `client.close()` on exit. | +| `AsyncClient` | `async with AsyncClient(...) as client:` | `await client.aclose()` on exit. | + +Context managers only close the root client. Online and batch sessions are +separate lifecycle boundaries and should also be closed through their own +context managers or service methods. + +## Authenticated client members + +Authentication returns an authenticated client bound to one KSeF context and one +token pair. + +| Member | Returns | Notes | +| --- | --- | --- | +| `auth_tokens` | `AuthTokens` | Access and refresh token models used by this authenticated branch. | +| `access_token` | `str` | Bearer access-token string. Treat as a secret. | +| `refresh_token` | `str` | Refresh-token string. Treat as a secret. | +| `resume_state()` | `AuthenticationResumeState` | Serializable authentication state containing access and refresh tokens. | +| `online_session(form_code=...)` | Online session client | Opens one online invoice session. Async returns an awaitable async context-manager wrapper. | +| `resume_online_session(state)` | Online session client | Rebinds serialized `OnlineSessionResumeState` to the current authenticated transport. | +| `batch_session(...)` | Batch session client | Opens a batch session from a prepared batch or declared batch file. | +| `open_batch_session(...)` | Batch session client | Opens a batch session when caller owns encryption metadata. | +| `resume_batch_session(state)` | Batch session client | Rebinds serialized `BatchSessionResumeState` to the current authenticated transport. | +| `invoices` | `InvoicesService` | Metadata, direct download, export, package download, and waiting helpers. | +| `batch` | `BatchService` | High-level batch package preparation, upload, close, status, and UPO workflow. | +| `limits` | `LimitsClient` | Context, subject, and API rate-limit endpoints. | +| `tokens` | `TokensClient` | Token generation, status, listing, and revocation. | +| `certificates` | `CertificatesClient` | Certificate enrollment, retrieval, query, limits, and revocation. | +| `sessions` | `SessionManagementClient` | Authentication session listing and termination. | +| `invoice_sessions` | `InvoiceSessionsClient` | Online and batch invoice-session history. | +| `permissions` | `PermissionsClient` | Permission grants, queries, revokes, and operation status. | +| `raw` | `RawAuthenticatedClient` | Low-level authenticated endpoint groups. | + +`AuthenticationResumeState` owns bearer access and refresh tokens. +`OnlineSessionResumeState` and `BatchSessionResumeState` do not contain bearer +authentication; resume them through an authenticated client. + +Authenticated clients share the root client's transport and certificate cache. +They do not own an independent HTTP client. + +## HTTP client ownership + +| Setup | HTTP configuration source | Who closes HTTP resources | +| --- | --- | --- | +| `Client(Environment.TEST)` | SDK builds `httpx.Client` from `TransportConfig`. | `Client.close()` or context-manager exit. | +| `AsyncClient(Environment.TEST)` | SDK builds `httpx.AsyncClient` from `TransportConfig`. | `await AsyncClient.aclose()` or async context-manager exit. | +| `Client(..., http_client=http)` | Supplied `httpx.Client`. | Caller closes `http`. | +| `AsyncClient(..., http_client=http)` | Supplied `httpx.AsyncClient`. | Caller closes `http`. | + +When `http_client` is supplied, HTTP-client settings such as timeout, pool, +TLS, proxy, `trust_env`, HTTP/2, custom transports, and event hooks come from +that supplied object. SDK retry middleware still uses `transport_config.retry`. + +## Lifecycle errors + +| Error | Raised when | +| --- | --- | +| `KSeFClientClosedError` | A root client or lifecycle-guarded branch is used after close. | +| `KSeFUnsupportedEnvironmentError` | A TEST-only root branch, currently `testdata`, is accessed outside `Environment.TEST`. | + +## Related reference + + + + + diff --git a/docs/en/reference/errors.mdx b/docs/en/reference/errors.mdx new file mode 100644 index 0000000..c4a137f --- /dev/null +++ b/docs/en/reference/errors.mdx @@ -0,0 +1,119 @@ +--- +title: Error Reference +description: SDK exception hierarchy, attributes, KSeF exception codes, and polling timeout classes. +--- + +import { LinkCard } from '@astrojs/starlight/components'; + +ksef2 raises SDK exceptions for failures it can classify. Transport failures +raised before KSeF returns a parsed response remain `httpx.HTTPError` +exceptions. + +## Base classes + +| Class | Base | `code` | Main attributes | +| --- | --- | --- | --- | +| `KSeFException` | `Exception` | `SDK_ERROR` | `context` | +| `KSeFApiError` | `KSeFException` | `API_ERROR` | `status_code`, `exception_code`, `response` | +| `KSeFAuthError` | `KSeFApiError` | `AUTH_ERROR` | `status_code`, `exception_code`, `response` | +| `KSeFRateLimitError` | `KSeFApiError` | `RATE_LIMIT_ERROR` | `retry_after`, `status_code`, `response` | + +Catch narrower subclasses before `KSeFException` when the workflow has a +specific recovery action. + +```python +try: + result = auth.invoices.query_metadata(filters=filters) +except KSeFRateLimitError as exc: + retry_after = exc.retry_after +except KSeFApiError as exc: + status_code = exc.status_code + exception_code = exc.exception_code +except KSeFException as exc: + context = exc.context +except httpx.HTTPError as exc: + transport_error = exc +``` + +## SDK exception classes + +| Class | `code` | Raised for | +| --- | --- | --- | +| `KSeFClientClosedError` | `CLIENT_CLOSED` | Root client or session client used after close. | +| `KSeFUnsupportedEnvironmentError` | `UNSUPPORTED_ENVIRONMENT` | TEST-only branch or flow used outside `Environment.TEST`. | +| `KSeFValidationError` | `VALIDATION_ERROR` | Invalid SDK input, invalid response payload, invalid profile config, or invalid session/batch arguments. | +| `KSeFInvoiceRenderingError` | `INVOICE_RENDERING_ERROR` | Optional XSLT/PDF rendering failures. | +| `KSeFEncryptionError` | `ENCRYPTION_ERROR` | Token, symmetric-key, invoice encryption, or decryption failure. | +| `KSeFSessionError` | `SESSION_ERROR` | Session-state violation, such as using a closed session. | +| `NoCertificateAvailableError` | `NO_CERTIFICATE_AVAILABLE` | No valid certificate exists for signing or encryption usage. | +| `KSeFMetadataPaginationError` | `METADATA_PAGINATION_ERROR` | Metadata pagination cannot continue safely. | + +## API error attributes + +`KSeFApiError` is raised for parsed KSeF 4xx and 5xx responses. Specialized +subclasses are used for authentication/authorization failures and rate limits. + +| Attribute | Type | Meaning | +| --- | --- | --- | +| `status_code` | `int` | HTTP status returned by KSeF. | +| `exception_code` | `ExceptionCode` | Normalized KSeF exception code when one is recognized. | +| `response` | `BaseModel | None` | Parsed KSeF error payload when parsing succeeded. | + +Use `response.model_dump()` or `response.model_dump_json()` for structured +diagnostics when `response` is not `None`. + +## ExceptionCode values + +| Name | Value | +| --- | --- | +| `UNKNOWN_ERROR` | `10000` | +| `OBJECT_ALREADY_EXISTS` | `30001` | +| `VALIDATION_ERROR` | `21405` | +| `UPO_NOT_FOUND` | `21178` | +| `NOT_PROCESSED_YET` | `21165` | + +Unknown numeric KSeF codes map to `ExceptionCode.UNKNOWN_ERROR`. + +## Polling timeout classes + +Polling timeout exceptions mean the local wait deadline expired. They do not by +themselves prove the remote KSeF workflow failed. + +| Class | `code` | Identifier attributes | +| --- | --- | --- | +| `KSeFAuthPollingTimeoutError` | `AUTH_POLLING_TIMEOUT` | `reference_number`, `timeout` | +| `KSeFTokenStatusTimeoutError` | `TOKEN_STATUS_TIMEOUT` | `reference_number`, `timeout` | +| `KSeFInvoiceQueryTimeoutError` | `INVOICE_QUERY_TIMEOUT` | `timeout` | +| `KSeFInvoiceDownloadTimeoutError` | `INVOICE_DOWNLOAD_TIMEOUT` | `ksef_number`, `timeout` | +| `KSeFInvoiceProcessingTimeoutError` | `INVOICE_PROCESSING_TIMEOUT` | `invoice_reference_number`, `timeout` | +| `KSeFExportTimeoutError` | `EXPORT_TIMEOUT` | `reference_number`, `timeout` | +| `KSeFBatchSessionTimeoutError` | `BATCH_SESSION_TIMEOUT` | `reference_number`, `timeout` | + +Store the relevant reference before polling so another process can resume the +status check. + +## Rate limit attributes + +| Attribute | Type | Meaning | +| --- | --- | --- | +| `retry_after` | `int | None` | Seconds from KSeF `Retry-After`; `None` when absent. | +| `status_code` | `int` | Always `429`. | +| `response` | `BaseModel | None` | Parsed KSeF error payload when available. | + +## Related reference + + + + diff --git a/docs/en/reference/low-level/authentication.mdx b/docs/en/reference/low-level/authentication.mdx new file mode 100644 index 0000000..3b9fda3 --- /dev/null +++ b/docs/en/reference/low-level/authentication.mdx @@ -0,0 +1,94 @@ +--- +title: Low-level Authentication +description: Raw authentication endpoint methods, token and XAdES sequence contracts, and SDK token binding. +--- + +import { LinkCard } from '@astrojs/starlight/components'; + +Use low-level authentication when another system owns signing, token encryption, +or polling policy. Raw authentication uses schema-native response models until +you explicitly map tokens back to the public SDK model. + +## Endpoint methods + +| Method | Transport | Returns | Use for | +| --- | --- | --- | --- | +| `client.raw.auth.challenge()` | unauthenticated | `spec.AuthenticationChallengeResponse` | Create the challenge used by token or XAdES authentication. | +| `client.raw.auth.token_auth(body)` | unauthenticated | `spec.AuthenticationInitResponse` | Start KSeF-token authentication with `InitTokenAuthenticationRequest`. | +| `client.raw.auth.xades_auth(signed_xml, verify_chain=False)` | unauthenticated | `spec.AuthenticationInitResponse` | Submit externally signed XAdES XML. | +| `client.raw.auth.auth_status(bearer_token, reference_number)` | temporary auth bearer | `spec.AuthenticationOperationStatusResponse` | Poll authentication operation status. | +| `client.raw.auth.redeem_token(bearer_token)` | temporary auth bearer | `spec.AuthenticationTokensResponse` | Exchange the temporary authentication token for access and refresh tokens. | +| `client.raw.auth.refresh_token(bearer_token)` | refresh bearer | `spec.AuthenticationTokenRefreshResponse` | Refresh an access token. | +| `auth.raw.auth.list_sessions(...)` | authenticated | `spec.AuthenticationListResponse` | List authentication sessions. | +| `auth.raw.auth.terminate_current_session()` | authenticated | `None` | Terminate the current authentication session. | +| `auth.raw.auth.terminate_auth_session(reference_number)` | authenticated | `None` | Terminate another authentication session by reference. | + +Async clients expose the same method names and await network calls. + +## Token authentication sequence + +| Step | Call or object | Keep | +| --- | --- | --- | +| Create challenge | `client.raw.auth.challenge()` | `challenge.challenge`, `challenge.timestampMs` | +| Fetch token-encryption certificate | `client.raw.encryption.fetch_public_certificates()` | certificate where usage contains `KsefTokenEncryption` | +| Encrypt token payload | `encrypt_token(ksef_token, str(challenge.timestampMs), cert.certificate)` | encrypted token payload | +| Build request | `spec.InitTokenAuthenticationRequest(...)` | `challenge`, `contextIdentifier`, `encryptedToken`, `publicKeyId` | +| Start operation | `client.raw.auth.token_auth(request)` | `init.authenticationToken.token`, `init.referenceNumber` | +| Poll | `client.raw.auth.auth_status(...)` | final status code | +| Redeem | `client.raw.auth.redeem_token(init.authenticationToken.token)` | schema-native access and refresh token response | +| Bind to SDK | `client.authentication.resume(AuthenticationResumeState.from_tokens(auth_mapper.from_spec(raw_tokens)))` | `AuthenticatedClient` | + +## XAdES external signing sequence + +| Step | Call or object | Keep | +| --- | --- | --- | +| Create challenge | `client.raw.auth.challenge()` | challenge payload for the signing system | +| Sign outside SDK | HSM, gateway, or external signer | signed XML bytes | +| Start operation | `client.raw.auth.xades_auth(signed_xml, verify_chain=True)` | `init.authenticationToken.token`, `init.referenceNumber` | +| Poll | `client.raw.auth.auth_status(...)` | final status code | +| Redeem | `client.raw.auth.redeem_token(init.authenticationToken.token)` | schema-native access and refresh token response | +| Bind to SDK | `client.authentication.resume(AuthenticationResumeState.from_tokens(auth_mapper.from_spec(raw_tokens)))` | `AuthenticatedClient` | + +## Binding raw tokens + +```python +from ksef2.models import AuthenticationResumeState +from ksef2.raw.mappers import auth as auth_mapper + +raw_tokens = client.raw.auth.redeem_token(init.authenticationToken.token) +auth_tokens = auth_mapper.from_spec(raw_tokens) +auth = client.authentication.resume(AuthenticationResumeState.from_tokens(auth_tokens)) +``` + +After binding, you can use workflow branches such as `auth.invoices` or +authenticated raw branches such as `auth.raw.invoices`. + +## Polling status contract + +`auth_status()` uses the temporary authentication token as a bearer token, not +the final access token. Poll until KSeF returns a terminal authentication status, +then call `redeem_token()` with the same temporary token. + +| Status condition | Caller action | +| --- | --- | +| Success status | Redeem the temporary authentication token. | +| Failure status | Stop and surface the status code/description. | +| Local timeout | Persist `init.referenceNumber` and decide whether another worker should continue polling. | + +## Related reference + + + + diff --git a/docs/en/reference/low-level/endpoint-map.mdx b/docs/en/reference/low-level/endpoint-map.mdx new file mode 100644 index 0000000..e87acd3 --- /dev/null +++ b/docs/en/reference/low-level/endpoint-map.mdx @@ -0,0 +1,56 @@ +--- +title: Low-level Endpoint Map +description: Find the raw ksef2 endpoint branch and method group for each KSeF area. +--- + +Low-level endpoint groups are thin facades over SDK endpoint wrappers. They are +available through `client.raw` before authentication and `auth.raw` after +authentication. + +## Before authentication + +| Branch | Methods | +| --- | --- | +| `client.raw.auth` | `challenge`, `token_auth`, `xades_auth`, `auth_status`, `redeem_token`, `refresh_token` | +| `client.raw.encryption` | `fetch_public_certificates` | +| `client.raw.peppol` | `query_providers` | +| `client.raw.testdata` | `create_subject`, `delete_subject`, `create_person`, `delete_person`, `grant_permissions`, `revoke_permissions`, `enable_attachments`, `revoke_attachments`, `block_context`, `unblock_context` | + +`client.raw.testdata` is available only in `Environment.TEST`. + +## After authentication + +| Branch | Methods | +| --- | --- | +| `auth.raw.auth` | `list_sessions`, `terminate_current_session`, `terminate_auth_session`; also authentication methods when bound to authenticated transport | +| `auth.raw.certificates` | `get_limits`, `get_enrollment_data`, `enroll`, `get_enrollment_status`, `retrieve`, `revoke`, `query` | +| `auth.raw.encryption` | `fetch_public_certificates` | +| `auth.raw.invoices` | `query_metadata`, `export`, `get_export_status`, `download`, `send`, `get_session_status`, `list_session_invoices`, `list_failed_session_invoices`, `get_session_invoice_status`, `get_invoice_upo_by_ksef`, `get_invoice_upo_by_reference` | +| `auth.raw.limits` | `get_context_limits`, `get_subject_limits`, `get_api_rate_limits`, `set_session_limits`, `reset_session_limits`, `set_subject_limits`, `reset_subject_limits`, `set_api_rate_limits`, `reset_api_rate_limits`, `set_production_rate_limits` | +| `auth.raw.peppol` | `query_providers` | +| `auth.raw.permissions.grant` | `grant_person`, `grant_entity`, `grant_authorization`, `grant_indirect`, `grant_subunit`, `grant_administered_eu_entity`, `grant_eu_entity` | +| `auth.raw.permissions.revoke` | `revoke_person`, `revoke_authorization` | +| `auth.raw.permissions.query` | `query_entities_grants`, `query_personal_grants`, `query_attachments_status`, `query_authorizations_grants`, `query_eu_entities_grants`, `query_persons_grants`, `query_subordinate_entities_roles`, `query_subunits_grants` | +| `auth.raw.permissions.status` | `query_operation_status`, `query_entity_roles` | +| `auth.raw.session` | `open_online`, `terminate_online`, `open_batch`, `close_batch`, `get_session_upo`, `list_sessions` | +| `auth.raw.testdata` | Same methods as `client.raw.testdata`; available only in `Environment.TEST`. | +| `auth.raw.tokens` | `generate_token`, `list_tokens`, `token_status`, `revoke_token` | + +## Imports + +```python +from ksef2.raw import ( + encrypt_invoice, + encrypt_symmetric_key, + encrypt_token, + generate_session_key, + prepare_batch_package, + sha256_b64, + spec, + supp, +) +from ksef2.raw.mappers import auth as auth_mapper +``` + +Use `spec` and `supp` for supported schema-native models. Do not import +generated models from `ksef2.infra.schema.api` in application code. diff --git a/docs/en/reference/low-level/overview.mdx b/docs/en/reference/low-level/overview.mdx new file mode 100644 index 0000000..fa456f6 --- /dev/null +++ b/docs/en/reference/low-level/overview.mdx @@ -0,0 +1,81 @@ +--- +title: Low-level API +description: Schema-native endpoint wrappers, raw model namespaces, and low-level utility exports. +--- + +import { LinkCard } from '@astrojs/starlight/components'; + +The low-level API is the SDK surface for endpoint-level control without leaving +the SDK transport stack. It is exposed through `client.raw` before +authentication and `auth.raw` after authentication. + +Use it when a KSeF integration needs custom signing, caller-owned encryption +custody, exact OpenAPI-shaped payloads, or endpoint debugging. Most application +workflows should still start with the high-level clients. + +## Contract + +| Property | Low-level behavior | +| --- | --- | +| Transport | Reuses the same root-client middleware stack, retries, lifecycle checks, and exception mapping. | +| Authentication | `client.raw` is unauthenticated; `auth.raw` uses the authenticated bearer transport where KSeF requires it. | +| Models | Request and response objects use schema-native KSeF/OpenAPI field names such as `referenceNumber`, `publicKeyId`, and `authenticationToken`. | +| Parsing | Successful JSON responses are parsed into generated `spec` or supplemental `supp` Pydantic models. | +| Binary responses | Invoice XML and UPO downloads return `bytes`. | +| Async shape | Async low-level clients expose the same branch and method names, with awaited network calls. | + +## Model namespaces + +Import supported low-level models from `ksef2.raw`, not from internal generated +packages: + +```python +from ksef2.raw import spec, supp +``` + +| Namespace | Use for | +| --- | --- | +| `spec` | Generated OpenAPI request and response models. | +| `supp` | Supplemental models used where generated OpenAPI shapes need SDK-side support. | +| `ksef2.raw.mappers` | Explicit bridges from schema-native models back to public SDK models. | + +## Utility exports + +| Export | Use for | +| --- | --- | +| `encrypt_token` | Encrypt KSeF token authentication payloads. | +| `generate_session_key` | Generate AES key and IV for invoice/session/export encryption. | +| `encrypt_symmetric_key` | Encrypt local AES material with a KSeF public certificate. | +| `encrypt_invoice` | Encrypt invoice XML bytes for low-level online submission. | +| `sha256_b64` | Calculate base64 SHA-256 hashes for low-level payload metadata. | +| `prepare_batch_package` | Build encrypted batch package metadata when caller owns low-level batch flow. | + +## Levels of control + +| Level | SDK owns | Caller owns | Typical entry point | +| --- | --- | --- | --- | +| Workflow | Endpoint order, encryption, polling, and model mapping. | Business inputs and persistence. | `auth.invoices`, `auth.batch`, session clients. | +| Step-level | Protocol details for one SDK workflow step. | Ordering and persistence between steps. | `session.send_invoice()`, `session.wait_for_invoice_ready()`. | +| Low-level | Transport, response parsing, exception mapping. | Endpoint order, schema-native payloads, encryption custody, polling. | `client.raw`, `auth.raw`. | + +The main ownership rule is session consistency: if low-level code opens a +session, low-level code should usually close and inspect that session. If +high-level code opens a session, use the returned high-level session client. + +## Low-level section + + + + diff --git a/docs/en/reference/low-level/sessions-invoices.mdx b/docs/en/reference/low-level/sessions-invoices.mdx new file mode 100644 index 0000000..64d9e78 --- /dev/null +++ b/docs/en/reference/low-level/sessions-invoices.mdx @@ -0,0 +1,120 @@ +--- +title: Low-level Sessions and Invoices +description: Raw session, invoice, UPO, export, and encryption payload contracts. +--- + +import { LinkCard } from '@astrojs/starlight/components'; + +Use low-level session and invoice calls when your integration owns encryption +material, session opening, invoice submission, export encryption, or polling +order. + +## Caller-owned material + +| Value | Source | Needed for | +| --- | --- | --- | +| `aes_key` | `generate_session_key()` or caller key custody | Invoice/session/export encryption and later decryption. | +| `iv` | `generate_session_key()` or caller key custody | Encryption metadata sent to KSeF and local decrypt/encrypt operations. | +| `encrypted_key` | `encrypt_symmetric_key(aes_key, cert.certificate)` | `encryptedSymmetricKey` in session or export payloads. | +| `public_key_id` | KSeF public certificate metadata | `publicKeyId` in session or export payloads. | +| `referenceNumber` | KSeF open-session or export response | Later send, status, close, UPO, or export-status calls. | + +If a high-level helper created this material, prefer the matching high-level +resume or fetch method. Do not reconstruct encryption state from logs. + +## Raw session methods + +| Method | Returns | Use for | +| --- | --- | --- | +| `auth.raw.session.open_online(body)` | `spec.OpenOnlineSessionResponse` | Open an online session from `OpenOnlineSessionRequest`. | +| `auth.raw.session.terminate_online(reference_number)` | `None` | Close an online session by reference. | +| `auth.raw.session.open_batch(body)` | `spec.OpenBatchSessionResponse` | Open a batch session from `OpenBatchSessionRequest`. | +| `auth.raw.session.close_batch(reference_number)` | `None` | Close a batch session after uploading all parts. | +| `auth.raw.session.get_session_upo(reference_number, upo_reference_number)` | `bytes` | Download session UPO bytes. | +| `auth.raw.session.list_sessions(...)` | `spec.SessionsQueryResponse` | Query session history with filters and optional continuation token. | + +## Raw invoice methods + +| Method | Returns | Use for | +| --- | --- | --- | +| `auth.raw.invoices.query_metadata(body, **params)` | `spec.QueryInvoicesMetadataResponse` | Fetch one metadata page from `spec.InvoiceQueryFilters`. | +| `auth.raw.invoices.export(body)` | `spec.ExportInvoicesResponse` | Start an invoice export from `InvoiceExportRequest`. | +| `auth.raw.invoices.get_export_status(reference_number)` | `spec.InvoiceExportStatusResponse` | Check export package readiness. | +| `auth.raw.invoices.download(ksef_number)` | `bytes` | Download processed invoice XML by KSeF number. | +| `auth.raw.invoices.send(reference_number, body)` | `spec.SendInvoiceResponse` | Send one encrypted invoice to an open online session. | +| `auth.raw.invoices.get_session_status(reference_number)` | `spec.SessionStatusResponse` | Fetch online or batch session status. | +| `auth.raw.invoices.list_session_invoices(reference_number, ...)` | `spec.SessionInvoicesResponse` | List accepted/session invoices. | +| `auth.raw.invoices.list_failed_session_invoices(reference_number, ...)` | `spec.SessionInvoicesResponse` | List failed session invoices. | +| `auth.raw.invoices.get_session_invoice_status(reference_number, invoice_reference_number)` | `spec.SessionInvoiceStatusResponse` | Fetch status for one invoice in a session. | +| `auth.raw.invoices.get_invoice_upo_by_ksef(reference_number, ksef_number)` | `bytes` | Download invoice UPO by KSeF number. | +| `auth.raw.invoices.get_invoice_upo_by_reference(reference_number, invoice_reference_number)` | `bytes` | Download invoice UPO by session invoice reference. | + +Methods with `continuation_token` send it as `x-continuation-token`. + +## OpenOnlineSessionRequest payload + +| Field | Source | +| --- | --- | +| `formCode` | `spec.FormCode(...)` matching the invoice schema. | +| `encryption.encryptedSymmetricKey` | Base64 encoded `encrypted_key`. | +| `encryption.initializationVector` | Base64 encoded `iv`. | +| `encryption.publicKeyId` | KSeF public key id used for encryption. | + +Keep the returned `referenceNumber` together with `aes_key` and `iv`. + +## SendInvoiceRequest payload + +| Field | Source | +| --- | --- | +| `invoiceHash` | `sha256_b64(invoice_xml)` | +| `invoiceSize` | `len(invoice_xml)` | +| `encryptedInvoiceHash` | `sha256_b64(encrypted_invoice)` | +| `encryptedInvoiceSize` | `len(encrypted_invoice)` | +| `encryptedInvoiceContent` | Base64 encoded encrypted invoice bytes. | + +`encrypt_invoice(xml_bytes=invoice_xml, key=aes_key, iv=iv)` produces the +encrypted invoice bytes. Low-level send does not poll; use +`get_session_invoice_status()` until the invoice has a final status. + +## Export payload + +`auth.raw.invoices.export()` accepts `InvoiceExportRequest`. The caller owns +the same encryption fields as sessions: + +| Field group | Source | +| --- | --- | +| `encryption` | Encrypted AES material and `publicKeyId`. | +| `filters` | `spec.InvoiceQueryFilters`. | +| `compressionType` | `spec.CompressionType`, usually ZIP. | + +Use high-level `auth.invoices.fetch_package_bytes(...)` only when you still +have the AES key and IV required to decrypt package parts. + +## Batch package handoff + +`prepare_batch_package` is exported from `ksef2.raw` for low-level batch flows. +It prepares encrypted ZIP package metadata and parts. The resulting batch file +metadata can be passed into: + +| Entry point | Use when | +| --- | --- | +| `auth.raw.session.open_batch(body)` | You want to own the full schema-native batch open request. | +| `auth.open_batch_session(...)` | You want SDK step-level session binding while keeping caller-owned encryption material. | + +## Related reference + + + + diff --git a/docs/en/reference/operations.mdx b/docs/en/reference/operations.mdx new file mode 100644 index 0000000..655da97 --- /dev/null +++ b/docs/en/reference/operations.mdx @@ -0,0 +1,208 @@ +--- +title: Operations Reference +description: Transport configuration, retry behavior, workflow timeouts, logging fields, and resumable KSeF references. +--- + +import { LinkCard } from '@astrojs/starlight/components'; + +This page records operational behavior that affects production integrations. +It is not a sending or authentication walkthrough. + +## TransportConfig + +`TransportConfig` is used only when the SDK creates the underlying `httpx` +client. If you pass `http_client`, that supplied client owns HTTP timeout, pool, +TLS, proxy, `trust_env`, HTTP/2, custom transport, and event-hook settings. + +```python +TransportConfig( + timeouts=TimeoutConfig(), + pool=ConnectionPoolConfig(), + retry=RetryConfig(), + tls=TlsConfig(), + proxy_url=None, + trust_env=True, + http2=True, +) +``` + +| Field | Type | Default | Passed to | +| --- | --- | --- | --- | +| `timeouts` | `TimeoutConfig` | `TimeoutConfig()` | `httpx.Timeout` | +| `pool` | `ConnectionPoolConfig` | `ConnectionPoolConfig()` | `httpx.Limits` | +| `retry` | `RetryConfig` | `RetryConfig()` | SDK retry middleware | +| `tls` | `TlsConfig` | `TlsConfig()` | `httpx` TLS verification | +| `proxy_url` | `str | None` | `None` | `httpx` proxy | +| `trust_env` | `bool` | `True` | `httpx` environment-variable behavior | +| `http2` | `bool` | `True` | `httpx` HTTP/2 flag | + +## TimeoutConfig + +`TimeoutConfig` controls one HTTP request. It does not control how long the SDK +polls KSeF workflow state. + +| Field | Default | Meaning | +| --- | --- | --- | +| `connect` | `5.0` | Time allowed to establish a connection. | +| `read` | `30.0` | Time allowed while waiting for response bytes. | +| `write` | `30.0` | Time allowed while sending request bytes. | +| `pool` | `5.0` | Time allowed while waiting for a connection from the pool. | + +## ConnectionPoolConfig + +| Field | Default | Meaning | +| --- | --- | --- | +| `max_connections` | `100` | Maximum open connections. | +| `max_keepalive_connections` | `20` | Maximum idle keep-alive connections. | +| `keepalive_expiry` | `30.0` | Idle keep-alive expiry in seconds. | + +## TlsConfig + +| Field | Default | Meaning | +| --- | --- | --- | +| `verify` | `True` | Passed to `httpx` unless `ca_bundle_path` is set. | +| `ca_bundle_path` | `None` | CA bundle path used as the `httpx` `verify` value when provided. | + +## RetryConfig + +`RetryConfig` controls SDK retry middleware. It applies to transport errors and +retryable HTTP responses for retryable request types. + +| Field | Default | +| --- | --- | +| `max_attempts` | `3` | +| `initial_delay` | `0.5` | +| `max_delay` | `4.0` | +| `backoff_multiplier` | `2.0` | +| `retryable_status_codes` | `(429, 502, 503, 504)` | + +Retry delay is exponential: + +```text +min(initial_delay * backoff_multiplier ** (attempt - 1), max_delay) +``` + +If KSeF sends `Retry-After`, the retry middleware uses that value, capped at +`max_delay`. + +## Retryable requests + +The SDK retries only request shapes that are safe enough to repeat at the +transport layer. + +| Request kind | Retry behavior | +| --- | --- | +| `GET` | Retryable. | +| `DELETE` | Retryable. | +| Most `POST` requests | Not retried automatically. | +| Selected `POST` requests | Retried automatically when listed below. | + +Retryable `POST` groups: + +| Area | Operations | +| --- | --- | +| Authentication | challenge, redeem token, refresh token | +| Invoices | metadata query | +| Certificates | query, retrieve | +| Permissions | personal, authorization, EU entity, person, subordinate entity, and subunit queries | + +Invoice submission, permission grants, certificate enrollment, token +generation, online-session opening, and batch-session opening are not retried by +the SDK retry middleware. Persist returned references and resume by status +instead of recreating those operations blindly. + +## Workflow polling timeouts + +Polling helpers use `timeout` and `poll_interval` arguments. Those values are +workflow deadlines, not HTTP request timeouts. When a polling timeout is raised, +the remote KSeF workflow may still finish later. + +| Workflow | Timeout exception | Identifier carried by exception | +| --- | --- | --- | +| Authentication polling | `KSeFAuthPollingTimeoutError` | `reference_number` | +| Token activation/status polling | `KSeFTokenStatusTimeoutError` | `reference_number` | +| Metadata visibility polling | `KSeFInvoiceQueryTimeoutError` | `timeout` only | +| Direct invoice download readiness | `KSeFInvoiceDownloadTimeoutError` | `ksef_number` | +| Online invoice processing | `KSeFInvoiceProcessingTimeoutError` | `invoice_reference_number` | +| Export package readiness | `KSeFExportTimeoutError` | `reference_number` | +| Batch session completion | `KSeFBatchSessionTimeoutError` | `reference_number` | + +Resume polling with stored identifiers after a timeout. Do not infer that the +remote operation failed only because the local wait deadline expired. + +## Rate limits + +KSeF `429` responses are classified as `KSeFRateLimitError`. The exception +exposes: + +| Attribute | Meaning | +| --- | --- | +| `retry_after` | Seconds from `Retry-After` when KSeF returned the header; otherwise `None`. | +| `status_code` | Always `429`. | +| `response` | Parsed KSeF error payload when available. | + +Use `retry_after` to schedule background work. In request handlers, prefer +returning or enqueueing retryable work over sleeping inside the request. + +## Secret and logging boundaries + +Do not log: + +- access tokens; +- refresh tokens; +- KSeF token values; +- private keys; +- PEM or PKCS#12 passwords; +- raw invoice XML unless your retention policy explicitly allows it; +- serialized online or batch session state. + +Useful production log fields: + +| Field | Meaning | +| --- | --- | +| `environment` | `test`, `demo`, or `production`. | +| `workflow` | Application-level operation name. | +| `session_reference_number` | Online or batch session reference. | +| `invoice_reference_number` | Per-invoice session reference. | +| `ksef_number` | Final KSeF invoice number after acceptance. | +| `operation_reference_number` | Token, permission, certificate, auth, or export operation reference. | +| `sdk_error_code` | `KSeFException.context["code"]` when present. | +| `exception_code` | KSeF exception code from `KSeFApiError.exception_code` when present. | + +## Resumable workflow state + +Most KSeF workflows start with a reference and finish through polling or later +download. Store the reference before waiting. + +| Workflow | Persist before waiting | +| --- | --- | +| Authentication | authentication operation reference and temporary authentication-token lifetime | +| Online invoice sending | session reference and invoice reference number | +| Batch upload | batch session reference, upload references, package metadata, and batch state while needed | +| Invoice export | export reference number and protected export handle material | +| Token generation | token reference number and returned token secret | +| Permission grants/revokes | permission operation reference number | +| Certificate enrollment/revoke | certificate operation reference or certificate serial number | + +## Related reference + + + + + diff --git a/docs/en/guides/public-api.md b/docs/en/reference/public-api.mdx similarity index 70% rename from docs/en/guides/public-api.md rename to docs/en/reference/public-api.mdx index 0c8e4b7..771fa36 100644 --- a/docs/en/guides/public-api.md +++ b/docs/en/reference/public-api.mdx @@ -3,6 +3,8 @@ title: Public API Contract description: Stable import paths and internal boundaries for ksef2 1.0. --- +import { LinkCard } from '@astrojs/starlight/components'; + Use the documented import paths on this page when building application code. They are the paths intended to remain stable through the 1.x line. @@ -12,19 +14,21 @@ They are the paths intended to remain stable through the 1.x line. | --- | --- | | `ksef2` | Root clients, environment and transport config, `FormSchema`, `__version__`, and public exceptions. | | `ksef2.clients` | Concrete sync and async client classes when you need them for type annotations. | -| `ksef2.domain.models` | SDK request, response, filter, pagination, token, permission, session, and batch models. | +| `ksef2.models` | SDK request, response, filter, pagination, token, permission, session, and batch models. | +| `ksef2.fa3` | FA(3) invoice builder, draft snapshots, and public FA(3) domain models used by builder workflows. | | `ksef2.xades` | Certificate loading, TEST certificate generation, local XAdES signing helpers, and `LocalSigner`. | | `ksef2.profiles` | Local `ksef2-cli` compatible profile config helpers. | +| `ksef2.renderers` | Optional local XSLT/PDF invoice rendering helpers. | | `ksef2.raw` | Low-level endpoint clients, schema-native `spec` and `supp` models, and low-level crypto helpers. | | `ksef2.raw.mappers` | Public mappers for crossing between raw schema models and SDK models. | -| `ksef2.services` | High-level service classes when you construct services manually instead of using client branches. | -| `ksef2.services.renderers` | Optional XSLT/PDF invoice rendering helpers. | Prefer the highest-level import that fits the workflow: ```python from ksef2 import Client, Environment, FormSchema, KSeFApiError -from ksef2.domain.models import InvoicesFilter, InvoiceMetadataParams +from ksef2.fa3 import FA3InvoiceBuilder, KsefInvoiceDraft, VatRate +from ksef2.models import InvoicesFilter, InvoiceMetadataParams +from ksef2.renderers import InvoicePDFExporter, InvoiceXSLTRenderer from ksef2.xades import load_certificate_from_pem, load_private_key_from_pem ``` @@ -63,6 +67,8 @@ Avoid these paths in application code: - `ksef2.infra.*`: generated schemas and mapper internals; - `ksef2.endpoints.*`: endpoint transport implementation; - `ksef2.core.*`: internal protocol, transport, middleware, and helper modules; +- `ksef2.services.*`: client-composed workflow service implementation; use + client branches or `ksef2.renderers`; - `scripts/*`: repository tooling, not package API. Some internal paths may still be importable for SDK implementation or tests, but @@ -76,6 +82,18 @@ preserve documented imports and behavior except for bug fixes. ## Reference -- [Client guide](client.md) -- [Low-level API](../raw/overview.md) -- [Sync code generation](../contributing/sync-generation.md) + + + diff --git a/docs/en/reference/release-notes-1-0-0.mdx b/docs/en/reference/release-notes-1-0-0.mdx new file mode 100644 index 0000000..227f8ea --- /dev/null +++ b/docs/en/reference/release-notes-1-0-0.mdx @@ -0,0 +1,118 @@ +--- +title: ksef2 1.0.0 Release Notes +description: Stability boundary, supported workflows, and pre-1.0 changes for the first stable ksef2 SDK release. +--- + +import { Aside, CardGrid, LinkCard } from '@astrojs/starlight/components'; + + + +ksef2 1.0.0 is the first release that treats documented application-facing +imports as a compatibility contract for the 1.x line. The SDK currently targets +KSeF OpenAPI version `2.6.1`. + +> **Unofficial SDK.** ksef2 is community-maintained. It is not published, +> endorsed, or supported by Poland's Ministry of Finance. The official KSeF +> documentation remains the source of truth for API behavior. + + + +## Stable in 1.0 + +The stable contract is the documented public SDK surface, not every importable +module in the repository. + +| Surface | 1.0 contract | +| --- | --- | +| `ksef2` | Root clients, environments, transport config, `FormSchema`, `__version__`, and public exceptions. | +| `ksef2.clients` | Concrete sync and async client classes for type annotations and advanced construction. | +| `ksef2.models` | Public SDK request, response, filter, pagination, token, permission, session, batch, and invoice models. | +| High-level client branches | `client.authentication`, `client.encryption`, `client.peppol`, `client.testdata`, and authenticated branches such as `auth.invoices`, `auth.tokens`, `auth.permissions`, `auth.certificates`, and `auth.limits`. | +| Session helpers | Online and batch session workflows for sending, polling, UPO, and resumable KSeF references. | +| `ksef2.xades` | Certificate loading, TEST certificate generation, local XAdES signing helpers, and `LocalSigner`. | +| `ksef2.profiles` | Local `ksef2-cli` compatible profile config helpers. | +| `ksef2.fa3` | Public FA(3) invoice builder, draft snapshots, and public FA(3) domain models used by builder workflows. | +| `ksef2.renderers` | Optional local XSLT/PDF invoice rendering helpers when installed with the `pdf` extra. | + +For the exact compatibility boundary, use the public API contract page. + +## Public but lower level + +`ksef2.raw` and `ksef2.raw.mappers` are public advanced APIs. Their import paths +are part of the 1.x contract, but their schema-native model shapes follow the +checked Ministry of Finance OpenAPI version. + +Use `ksef2.raw` when you need endpoint-level control, exact OpenAPI-shaped +payloads, caller-owned encryption custody, or protocol debugging. Most +application code should use the high-level client branches. + +## Not part of the 1.x contract + +Do not build application code on these paths: + +- `ksef2.infra.*`; +- `ksef2.core.*`; +- `ksef2.endpoints.*`; +- `ksef2.services.*`; +- repository `scripts/*`; +- underscored modules and deprecated compatibility modules; +- generated schema internals outside `ksef2.raw.spec` and `ksef2.raw.supp`. + +These paths may remain importable for SDK implementation, tests, and tooling, +but they can change without a 2.0 release. + +## What changed from pre-1.0 + +Pre-1.0 releases shaped the SDK around a stable application surface: + +- consolidated normal workflows around `Client` or `AsyncClient`, then + `client.authentication`, then authenticated branches such as `auth.invoices`; +- added matching sync and async client surfaces; +- exposed public `ksef2.xades` and `ksef2.profiles` facades; +- exposed `ksef2.raw` for schema-native endpoint access without importing + internal generated modules; +- documented public exception classes, KSeF API errors, rate-limit errors, and + local polling timeouts; +- added invoice metadata pagination, exports, package downloads, and direct + invoice downloads; +- added token, permission, certificate, limits, PEPPOL, and TEST-data workflows; +- added public FA(3) builder and draft APIs under `ksef2.fa3`; +- moved public documentation into `getting-started`, `concepts`, + `how-to-guides`, and `reference`. + +## Upgrade summary + +Move application imports to the documented public paths, retest authentication +and invoice workflows against TEST or DEMO, and replace old documentation links +with the current Starlight routes. + + + + + + + diff --git a/docs/en/workflows/building-invoices.mdx b/docs/en/workflows/building-invoices.mdx new file mode 100644 index 0000000..3c3130d --- /dev/null +++ b/docs/en/workflows/building-invoices.mdx @@ -0,0 +1,114 @@ +--- +title: Building Invoices +description: Build FA(3) invoice XML with typed ksef2 helpers before sending it to KSeF. +--- + +import { Aside, Steps } from '@astrojs/starlight/components'; + +Use `ksef2.fa3` when your application owns invoice data as Python objects and +you want the SDK to produce FA(3) XML. Use the sending workflows when you +already have XML from another system. + +## Build a standard invoice + +```python +from datetime import date +from decimal import Decimal + +from ksef2.fa3 import FA3InvoiceBuilder, VatRate + +builder = ( + FA3InvoiceBuilder() + .header(system_info="billing-service") + .seller( + name="ACME S.A.", + tax_id="1234567890", + country_code="PL", + address_line_1="ul. Przykladowa 123", + ) + .buyer( + name="XYZ GmbH", + country_code="DE", + address_line_1="Unter den Linden 1", + ) + .standard() + .issue_place("Warszawa") + .issue_date(date(2026, 3, 29)) + .invoice_number("FV/2026/03/0001") + .rows() + .add_line( + name="Consulting service", + supply_date=date(2026, 3, 29), + unit_of_measure="h", + quantity=Decimal("10"), + unit_price_net=Decimal("100.00"), + vat_rate=VatRate.VAT_23, + ) + .done() + .done() +) + +xml_bytes = builder.to_xml().encode("utf-8") +``` + +Each `.done()` returns to the previous builder level. + +## Choose an invoice kind + +- `standard()` +- `simplified()` +- `correction()` +- `advance()` +- `settlement()` +- `correction_advance()` +- `correction_settlement()` + +## Persist drafts + +Persist builder state when invoice editing can span multiple requests or user +sessions. + +```python +from ksef2.fa3 import FA3InvoiceBuilder, KsefInvoiceDraft + +json_text = builder.dump_state_json(indent=2) +draft = KsefInvoiceDraft.model_validate_json(json_text) +restored = FA3InvoiceBuilder.from_state(draft) +``` + +## Send builder output + +```python +from ksef2 import FormSchema + +with auth.online_session(form_code=FormSchema.FA3) as session: + status = session.send_invoice_and_wait(invoice_xml=xml_bytes) + print(status.ksef_number) +``` + + + +## Recommended flow + + + +1. Map application data into the FA(3) builder. + +2. Choose the invoice kind and fill required nested sections. + +3. Save draft state if users can pause editing. + +4. Render XML with `to_xml()`. + +5. Send the XML through an online or batch workflow. + + + +## Reference + +- [Sending invoices](sending-invoices.mdx) +- [FA(3) API reference](../reference/api/fa3.md) diff --git a/docs/en/workflows/certificates.mdx b/docs/en/workflows/certificates.mdx deleted file mode 100644 index ad3a1e4..0000000 --- a/docs/en/workflows/certificates.mdx +++ /dev/null @@ -1,94 +0,0 @@ ---- -title: Certificates -description: Check certificate limits, enroll certificates, query issued certificates, and revoke certificates. ---- - -import { Aside, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; - -Use `auth.certificates` for the KSeF certificate lifecycle. The SDK sends CSR -and lifecycle requests; your application or certificate tooling still owns key -generation and CSR creation. - -## Check limits and enrollment data - -```python -limits = auth.certificates.get_limits() -print(limits.can_request, limits.enrollment_remaining) - -subject = auth.certificates.get_enrollment_data() -print(subject.common_name, subject.country_name) -``` - -## Enroll and poll - -```python -enrollment = auth.certificates.enroll( - certificate_name="billing-service", - certificate_type="authentication", - csr="-----BEGIN CERTIFICATE REQUEST-----\n...\n-----END CERTIFICATE REQUEST-----", -) - -status = auth.certificates.get_enrollment_status( - reference_number=enrollment.reference_number, -) -print(status.status_code, status.certificate_serial_number) -``` - -## Retrieve, query, and revoke - - - - -```python -for certificate in auth.certificates.all(status="active"): - print(certificate.serial_number, certificate.name, certificate.valid_to) -``` - - - - -```python -result = auth.certificates.retrieve( - certificate_serial_numbers=["0123456789ABCDEF"], -) -``` - - - - -```python -auth.certificates.revoke( - certificate_serial_number="0123456789ABCDEF", - reason="key_compromise", -) -``` - - - - - - -## Recommended flow - - - -1. Check certificate and enrollment quotas. - -2. Fetch enrollment subject data. - -3. Generate a private key and CSR outside the SDK. - -4. Submit enrollment and persist the reference number. - -5. Poll status, retrieve the certificate, and store it with its private key. - - - -## Reference - -- [XAdES helpers](xades.mdx) -- [Certificates API](../reference/api/certificates.md) diff --git a/docs/en/workflows/client-setup.mdx b/docs/en/workflows/client-setup.mdx index 614dab1..a2a2639 100644 --- a/docs/en/workflows/client-setup.mdx +++ b/docs/en/workflows/client-setup.mdx @@ -37,6 +37,27 @@ async with AsyncClient(Environment.TEST) as client: Use `Environment.DEMO` or `Environment.PRODUCTION` outside local TEST workflows. +## Certificate cache + +Root clients use an in-memory certificate store by default. The store refreshes +public encryption certificates after 24 hours, and high-level authentication, +session, and export workflows load certificates lazily when they need them. + +Use a custom refresh interval when your application has a stricter startup or +rotation policy: + +```python +from datetime import timedelta + +from ksef2 import CertificateStore, Client, Environment + +store = CertificateStore(refresh_after=timedelta(hours=6)) +client = Client(Environment.PRODUCTION, certificate_store=store) +``` + +Pass any object implementing `CertificateStoreProtocol` when certificates should +be shared through application storage such as a database or cache. + When credentials live in a CLI-compatible profile, create the root client for the profile environment and authenticate through `with_profile()`: diff --git a/docs/en/workflows/downloading-invoices.mdx b/docs/en/workflows/downloading-invoices.mdx deleted file mode 100644 index 666fcc4..0000000 --- a/docs/en/workflows/downloading-invoices.mdx +++ /dev/null @@ -1,97 +0,0 @@ ---- -title: Downloading Invoices -description: Download processed invoice XML directly or through encrypted export packages with ksef2. ---- - -import { Aside, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; - -Use direct download when you already know one KSeF number. Use exports when you -need many invoices or want the SDK to download encrypted package parts. - -## Download one invoice - -```python -xml_bytes = auth.invoices.download_invoice(ksef_number="KSeF-number") - -with open("invoice.xml", "wb") as handle: - handle.write(xml_bytes) -``` - -If the invoice was just sent, poll until KSeF makes the processed XML -downloadable: - -```python -xml_bytes = auth.invoices.wait_for_invoice_download( - ksef_number="KSeF-number", - timeout=120.0, - poll_interval=2.0, -) -``` - -## Export many invoices - -Exports are asynchronous. Schedule an export from filters, wait for the package, -then fetch decrypted ZIP parts. - - - - -```python -export = auth.invoices.schedule_export(filters=filters) -package = auth.invoices.wait_for_export_package( - reference_number=export.reference_number, - timeout=300.0, -) - -for path in auth.invoices.fetch_package( - package=package, - export=export, - target_directory="downloads", -): - print(path) -``` - - - - -```python -zip_parts = auth.invoices.export_and_download( - filters=filters, - timeout=300.0, -) - -for part in zip_parts: - print(len(part)) -``` - - - - - - -## After sending invoices - -If your workflow sends and then downloads invoices, keep the two phases -separate: - - - -1. Send XML through an online or batch session. - -2. Persist the returned KSeF number or session reference. - -3. Poll session status or metadata until processing completes. - -4. Download by KSeF number, or build an export filter for the same period. - - - -## Next workflows - -- [Send invoices](sending-invoices.mdx) -- [Query invoices](querying-invoices.mdx) -- [Invoice retrieval API](../reference/api/invoice-retrieval.md) diff --git a/docs/en/workflows/encryption-certificates.mdx b/docs/en/workflows/encryption-certificates.mdx index 7ff3ee7..e42263e 100644 --- a/docs/en/workflows/encryption-certificates.mdx +++ b/docs/en/workflows/encryption-certificates.mdx @@ -10,6 +10,11 @@ material. High-level invoice and batch helpers load these certificates automatically. Use `client.encryption` directly when you need to inspect, cache, or pre-load certificate material. +By default, root clients use `CertificateStore`, an in-memory cache refreshed +after 24 hours. Pass `refresh_after=None` to keep fetch-once behavior for a +short-lived client, or pass a custom object implementing +`CertificateStoreProtocol` to integrate application storage. + ## Fetch certificates ```python @@ -27,6 +32,23 @@ certificates = client.encryption.get_certificates( ) ``` +Configure the default store at root-client construction: + +```python +from datetime import timedelta + +from ksef2 import CertificateStore, Client, Environment + +client = Client( + Environment.PRODUCTION, + certificate_store=CertificateStore(refresh_after=timedelta(hours=6)), +) +``` + +Custom stores implement `load()`, `get_valid()`, and `needs_refresh()`. The SDK +still owns remote fetching; the store owns cache persistence and freshness +decisions. + ## How the SDK uses them Authenticated invoice sessions and exports call the encryption certificate diff --git a/docs/en/workflows/limits.mdx b/docs/en/workflows/limits.mdx deleted file mode 100644 index a01813e..0000000 --- a/docs/en/workflows/limits.mdx +++ /dev/null @@ -1,88 +0,0 @@ ---- -title: Limits -description: Read and override KSeF context, subject, and API rate limits through ksef2. ---- - -import { Aside, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; - -Use `auth.limits` to inspect effective KSeF limits and, where your credentials -allow it, override limits for TEST or administrative contexts. - -## Read effective limits - - - - -```python -context = auth.limits.get_context_limits() -print(context.online_session.max_invoices) -print(context.batch_session.max_invoice_size_mb) -``` - - - - -```python -subject = auth.limits.get_subject_limits() -print(subject.certificate, subject.enrollment) -``` - - - - -```python -rate = auth.limits.get_api_rate_limits() -print(rate.invoice_metadata.per_minute) -``` - - - - -## Override and reset - -```python -from ksef2.domain.models.limits import ContextLimits, SessionLimits - -limits = ContextLimits( - online_session=SessionLimits( - max_invoice_size_mb=10, - max_invoice_with_attachment_size_mb=20, - max_invoices=100, - ), - batch_session=SessionLimits( - max_invoice_size_mb=10, - max_invoice_with_attachment_size_mb=20, - max_invoices=1000, - ), -) - -auth.limits.set_session_limits(limits=limits) -auth.limits.reset_session_limits() -``` - -Use `set_production_rate_limits()` when a TEST-like environment should mirror -production API rate defaults. - - - -## Recommended flow - - - -1. Read effective limits before choosing batch size or polling behavior. - -2. Use production defaults unless a controlled test needs a different limit. - -3. Apply overrides from an explicit admin workflow. - -4. Reset limits after the test or temporary change. - - - -## Reference - -- [Sending invoices](sending-invoices.mdx) -- [Limits API](../reference/api/limits.md) diff --git a/docs/en/workflows/peppol.mdx b/docs/en/workflows/peppol.mdx deleted file mode 100644 index ef93985..0000000 --- a/docs/en/workflows/peppol.mdx +++ /dev/null @@ -1,49 +0,0 @@ ---- -title: PEPPOL Providers -description: Query PEPPOL service providers registered in KSeF. ---- - -import { Steps, TabItem, Tabs } from '@astrojs/starlight/components'; - -Use `client.peppol` to inspect PEPPOL providers. This is a public root-client -branch and does not require an authenticated client. - -## Query providers - - - - -```python -page = client.peppol.query() - -for provider in page.providers: - print(provider.id, provider.name) -``` - - - - -```python -for provider in client.peppol.all(): - print(provider.id, provider.name, provider.date_created) -``` - - - - -## Recommended flow - - - -1. Query providers from the root client. - -2. Cache provider ids and names for user selection or validation. - -3. Refresh the cache according to your product's data freshness needs. - - - -## Reference - -- [Client setup](client-setup.mdx) -- [PEPPOL API](../reference/api/peppol.md) diff --git a/docs/en/workflows/permissions.mdx b/docs/en/workflows/permissions.mdx deleted file mode 100644 index 4c07a7d..0000000 --- a/docs/en/workflows/permissions.mdx +++ /dev/null @@ -1,121 +0,0 @@ ---- -title: Permissions -description: Grant, query, revoke, and monitor KSeF permissions with ksef2. ---- - -import { Aside, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; - -Use `auth.permissions` for permission grants, revocations, search, and -operation status checks. Permission operations return references; poll those -references before treating the change as complete. - -## Grant permissions - - - - -```python -operation = auth.permissions.grant_person( - subject_type="pesel", - subject_value="90010112345", - permissions=["invoice_read"], - description="Read invoices", - first_name="Jan", - last_name="Kowalski", -) -``` - - - - -```python -from ksef2.domain.models import EntityPermission - -operation = auth.permissions.grant_entity( - subject_value="1234567890", - permissions=[EntityPermission(type="invoice_read", can_delegate=False)], - description="Accounting office read access", - entity_name="Accounting Sp. z o.o.", -) -``` - - - - -```python -operation = auth.permissions.grant_authorization( - subject_type="nip", - subject_value="1234567890", - permission="self_invoicing", - description="Self-invoicing agreement", - entity_name="Partner Sp. z o.o.", -) -``` - - - - -## Check operation status - -```python -status = auth.permissions.get_operation_status( - reference_number=operation.reference_number, -) -print(status.status.code, status.status.description) -``` - -## Query and revoke - -```python -from ksef2.domain.models import PersonalPermissionsQuery - -page = auth.permissions.query_personal( - query=PersonalPermissionsQuery(permission_types=["invoice_read"]), -) - -for permission in page.permissions: - print(permission.id, permission.permission_state) -``` - -Use the returned permission id for revocation: - -```python -auth.permissions.revoke_common(permission_id="permission-id") -auth.permissions.revoke_authorization(permission_id="authorization-id") -``` - -## Attachments - -```python -status = auth.permissions.get_attachment_permission_status() -print(status.is_attachment_allowed) -``` - - - -## Recommended flow - - - -1. Grant the smallest permission set required by the target subject. - -2. Persist the operation reference returned by KSeF. - -3. Poll operation status before exposing the permission in your application. - -4. Query permissions to collect ids for audits or revocation. - -5. Revoke by permission id when access should end. - - - -## Reference - -- [Permission grants API](../reference/api/permission-grants.md) -- [Permission search API](../reference/api/permission-search.md) -- [Permission operations API](../reference/api/permission-operations.md) -- [Permission revokes API](../reference/api/permission-revokes.md) diff --git a/docs/en/workflows/querying-invoices.mdx b/docs/en/workflows/querying-invoices.mdx deleted file mode 100644 index 97c6fb7..0000000 --- a/docs/en/workflows/querying-invoices.mdx +++ /dev/null @@ -1,108 +0,0 @@ ---- -title: Querying Invoices -description: Query invoice metadata with ksef2 filters, pagination helpers, and polling. ---- - -import { Aside, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; - -Use `auth.invoices` when you need invoice metadata outside a sending session. -Metadata queries are filter-driven and paginated by KSeF. - -## Build a filter - -Keep filters narrow. Date ranges, role, and invoice type make queries faster and -reduce pagination work. - -```python -from datetime import datetime, timedelta, timezone - -from ksef2.domain.models import InvoicesFilter - -filters = InvoicesFilter( - role="seller", - date_type="issue_date", - date_from=datetime.now(tz=timezone.utc) - timedelta(days=7), - date_to=datetime.now(tz=timezone.utc), - amount_type="brutto", - invoicing_mode="online", - invoice_types=["vat"], -) -``` - -## Query one page or all pages - - - - -```python -from ksef2.domain.models import InvoiceMetadataParams - -page = auth.invoices.query_metadata( - filters=filters, - params=InvoiceMetadataParams(sort_order="asc"), -) - -for invoice in page.invoices: - print(invoice.ksef_number, invoice.invoice_number) -``` - - - - -```python -for page in auth.invoices.query_metadata_pages(filters=filters): - print(len(page.invoices), page.has_more) -``` - - - - -```python -for invoice in auth.invoices.all_metadata(filters=filters): - print(invoice.ksef_number, invoice.invoice_number) -``` - - - - -## Wait for a newly sent invoice - -KSeF processing is asynchronous. After sending an invoice, poll metadata when -your next step depends on the invoice becoming visible in retrieval APIs. - -```python -result = auth.invoices.wait_for_invoices( - filters=filters, - timeout=120.0, - poll_interval=2.0, -) - -for invoice in result.invoices: - print(invoice.ksef_number) -``` - - - -## Recommended flow - - - -1. Decide which subject role you are querying as. - -2. Build a narrow `InvoicesFilter`. - -3. Fetch one page for interactive screens or use iterators for background jobs. - -4. Persist KSeF numbers needed for later downloads. - - - -## Next workflows - -- [Send invoices](sending-invoices.mdx) -- [Download invoices](downloading-invoices.mdx) -- [Invoice retrieval API](../reference/api/invoice-retrieval.md) diff --git a/docs/en/workflows/sending-invoices.mdx b/docs/en/workflows/sending-invoices.mdx deleted file mode 100644 index 4ffba50..0000000 --- a/docs/en/workflows/sending-invoices.mdx +++ /dev/null @@ -1,158 +0,0 @@ ---- -title: Sending Invoices -description: Send FA(3) XML through ksef2 online sessions or batch sessions. ---- - -import { Aside, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; - -ksef2 supports two sending workflows: - -- **Online sessions** for interactive, short-lived submission. -- **Batch sessions** for larger sets of invoice XML files. - -Both workflows encrypt invoice payloads before upload and return session -references that you can use for status checks. - -## Online session - -Use an online session when you want to send one or a few invoices and get a KSeF -number back in the same process. - -```python -from pathlib import Path - -from ksef2 import FormSchema - -with auth.online_session(form_code=FormSchema.FA3) as session: - status = session.send_invoice_and_wait( - invoice_xml=Path("invoice.xml").read_bytes(), - timeout=60.0, - poll_interval=2.0, - ) - print(status.ksef_number) -``` - -The context manager closes the online session when the block exits. - -## Poll manually - -Use the lower-level calls when you need to persist the invoice reference number -or attach your own retry policy. - -```python -from pathlib import Path - -with auth.online_session(form_code=FormSchema.FA3) as session: - result = session.send_invoice(invoice_xml=Path("invoice.xml").read_bytes()) - print(result.reference_number) - - status = session.wait_for_invoice_ready( - invoice_reference_number=result.reference_number, - timeout=120.0, - ) - print(status.ksef_number) -``` - -## Batch session - -Use batch sessions when you need to submit many XML files as one batch. The -service can prepare a package, upload all parts, close the session, and then -poll until processing finishes. - - - - -```python -from pathlib import Path - -from ksef2 import FormSchema - -prepared = auth.batch.prepare_batch_from_paths( - invoice_paths=[ - Path("invoice-1.xml"), - Path("invoice-2.xml"), - ], - form_code=FormSchema.FA3, -) - -state = auth.batch.submit_prepared_batch(prepared_batch=prepared) -final_status = auth.batch.wait_for_completion(session=state, timeout=300.0) -print(final_status.reference_number) -``` - - - - -```python -from pathlib import Path - -from ksef2 import FormSchema -from ksef2.domain.models import BatchInvoice - -state = auth.batch.submit_batch( - invoices=[ - BatchInvoice( - file_name="invoice-1.xml", - content=Path("invoice-1.xml").read_bytes(), - ), - BatchInvoice( - file_name="invoice-2.xml", - content=Path("invoice-2.xml").read_bytes(), - ), - ], - form_code=FormSchema.FA3, -) - -final_status = auth.batch.wait_for_completion(session=state, timeout=300.0) -print(final_status.reference_number) -``` - - - - -## Inspect results - -```python -accepted = auth.batch.list_invoices(session=state) -failed = auth.batch.list_failed_invoices(session=state) - -print(len(accepted.invoices), len(failed.invoices)) -``` - -If the final status exposes an UPO reference, download the collective UPO with: - -```python -upo_xml = auth.batch.get_upo( - session=state, - upo_reference_number="upo-reference-from-status", -) -``` - - - -## Recommended flow - - - -1. Authenticate for the seller context. - -2. Choose online or batch sending. - -3. Send XML and persist returned references. - -4. Poll until KSeF returns a terminal result. - -5. Query metadata or download invoice XML after KSeF processing completes. - - - -## Next workflows - -- [Query invoices](querying-invoices.mdx) -- [Download invoices](downloading-invoices.mdx) -- [Interactive sending API](../reference/api/interactive-sending.md) -- [Status and UPO API](../reference/api/status-upo.md) diff --git a/docs/en/workflows/status-upo.mdx b/docs/en/workflows/status-upo.mdx deleted file mode 100644 index 3d784eb..0000000 --- a/docs/en/workflows/status-upo.mdx +++ /dev/null @@ -1,96 +0,0 @@ ---- -title: Status and UPO -description: Check online and batch session status, list session invoices, and download UPO documents. ---- - -import { Aside, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; - -KSeF processing is asynchronous. Keep session references and invoice reference -numbers so you can inspect status and download UPO documents after the sending -process exits. - -## Online session status - -```python -with auth.online_session(form_code=FormSchema.FA3) as session: - result = session.send_invoice(invoice_xml=xml_bytes) - invoice_status = session.wait_for_invoice_ready( - invoice_reference_number=result.reference_number, - timeout=120.0, - ) - upo_xml = session.get_invoice_upo_by_reference( - invoice_reference_number=result.reference_number, - ) -``` - -## Batch status - -```python -state = auth.batch.submit_prepared_batch(prepared_batch=prepared) -final_status = auth.batch.wait_for_completion(session=state, timeout=300.0) - -accepted = auth.batch.list_invoices(session=state) -failed = auth.batch.list_failed_invoices(session=state) -``` - -If KSeF returns an UPO reference for the batch, use it to download the document: - -```python -upo_xml = auth.batch.get_upo( - session=state, - upo_reference_number="upo-reference-from-status", -) -``` - -## Historical session browsing - -Use `auth.invoice_sessions` when you need to find sessions after process -restart. - - - - -```python -page = auth.invoice_sessions.query( - session_type="online", - statuses=["processing", "completed"], -) -``` - - - - -```python -for page in auth.invoice_sessions.all(session_type="batch"): - for item in page.sessions: - print(item.reference_number, item.status) -``` - - - - - - -## Recommended flow - - - -1. Persist session and invoice references returned by KSeF. - -2. Poll the session or invoice status until it reaches a terminal state. - -3. Persist accepted and failed invoice details. - -4. Download and store the relevant UPO document. - - - -## Reference - -- [Sending invoices](sending-invoices.mdx) -- [Status and UPO API](../reference/api/status-upo.md) -- [Active sessions API](../reference/api/active-sessions.md) diff --git a/docs/en/workflows/test-data.mdx b/docs/en/workflows/test-data.mdx deleted file mode 100644 index 2cf3163..0000000 --- a/docs/en/workflows/test-data.mdx +++ /dev/null @@ -1,81 +0,0 @@ ---- -title: TEST Data -description: Create sandbox subjects, people, permissions, attachment flags, and blocked contexts in TEST. ---- - -import { Aside, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; - -Use `client.testdata` only in `Environment.TEST`. These helpers mutate sandbox -data so your tests and demos can create known contexts. - -## Create and clean up manually - -```python -client.testdata.create_subject( - nip="5261040828", - subject_type="vat_group", - description="Sandbox company", -) - -client.testdata.enable_attachments(nip="5261040828") - -client.testdata.delete_subject(nip="5261040828") -``` - -## Use temporal cleanup - -The temporal helper records mutations and attempts cleanup when the block exits. - - - - -```python -with client.testdata.temporal() as data: - data.create_subject( - nip="5261040828", - subject_type="vat_group", - description="Integration test subject", - ) - data.enable_attachments(nip="5261040828") -``` - - - - -```python -from ksef2.domain.models import Identifier, Permission - -with client.testdata.temporal() as data: - data.grant_permissions( - permissions=[Permission(type="invoice_read", description="Read invoices")], - grant_to=Identifier(type="nip", value="1111111111"), - in_context_of=Identifier(type="nip", value="5261040828"), - ) -``` - - - - - - -## Recommended flow - - - -1. Create subjects, people, permissions, or attachment flags required by a test. - -2. Use `temporal()` for fixtures that should be cleaned up automatically. - -3. Use direct create/delete methods when setup is shared across many test runs. - -4. Keep generated identifiers in test config, not production config. - - - -## Reference - -- [Client setup](client-setup.mdx) -- [TEST data API](../reference/api/testdata.md) diff --git a/docs/en/workflows/tokens.mdx b/docs/en/workflows/tokens.mdx deleted file mode 100644 index 48dc7a5..0000000 --- a/docs/en/workflows/tokens.mdx +++ /dev/null @@ -1,77 +0,0 @@ ---- -title: Tokens -description: Generate, list, inspect, and revoke KSeF tokens through ksef2. ---- - -import { Aside, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; - -Use `auth.tokens` when an authenticated context needs to create or retire KSeF -tokens for automation. Treat generated token values as secrets. - -## Generate a token - -```python -token = auth.tokens.generate( - permissions=["invoice_read"], - description="nightly export", - timeout=60.0, -) - -print(token.reference_number) -print(token.token) -``` - - - -## List and inspect tokens - - - - -```python -for page in auth.tokens.list_all(): - for item in page.tokens: - print(item.reference_number, item.status, item.description) -``` - - - - -```python -status = auth.tokens.status(reference_number="token-reference") -print(status.status) -``` - - - - -## Revoke a token - -```python -auth.tokens.revoke(reference_number="token-reference") -``` - -## Recommended flow - - - -1. Choose the smallest permission set required by the automation. - -2. Generate the token in the owning authenticated context. - -3. Store the token value in a secret store and the reference number in metadata. - -4. List or inspect references during audits. - -5. Revoke unused or compromised tokens. - - - -## Reference - -- [Authentication](authentication.mdx) -- [Tokens API](../reference/api/tokens.md) diff --git a/docs/en/workflows/xades.mdx b/docs/en/workflows/xades.mdx deleted file mode 100644 index 8e1563f..0000000 --- a/docs/en/workflows/xades.mdx +++ /dev/null @@ -1,104 +0,0 @@ ---- -title: XAdES Helpers -description: Load certificates and private keys, generate TEST certificates, and sign XML for KSeF authentication. ---- - -import { Aside, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; - -Use XAdES helpers when you authenticate with certificate material or need to -sign the authentication token request locally. Higher-level authentication -methods call these helpers for the common paths. - -## Load certificate material - - - - -```python -import os - -from ksef2.xades import load_certificate_from_pem, load_private_key_from_pem - -password = os.environ.get("KSEF2_KEY_PASSWORD") -cert = load_certificate_from_pem("company.pem") -private_key = load_private_key_from_pem( - "company.key", - password=password.encode() if password else None, -) -``` - - - - -```python -import os - -from ksef2.xades import load_certificate_and_key_from_p12 - -password = os.environ.get("KSEF2_P12_PASSWORD") -cert, private_key = load_certificate_and_key_from_p12( - "company.p12", - password=password.encode() if password else None, -) -``` - - - - -## Authenticate with XAdES - -```python -auth = client.authentication.with_xades( - nip="5261040828", - cert=cert, - private_key=private_key, -) -``` - -## Generate TEST certificates - -```python -from ksef2.xades import generate_test_certificate - -cert, private_key = generate_test_certificate(nip="5261040828") -``` - -For personal TEST identities, use `generate_personal_test_certificate()`. - -## Sign XML directly - -```python -from ksef2.xades import build_auth_token_request_xml, sign_xades - -xml = build_auth_token_request_xml( - challenge="challenge-from-ksef", - nip="5261040828", -) -signed_xml = sign_xades(xml, cert, private_key) -``` - - - -## Recommended flow - - - -1. Load certificate material from PEM or PKCS#12. - -2. Keep private-key passwords in environment variables. - -3. Authenticate through `with_xades()` when possible. - -4. Use direct signing helpers only for low-level integration work. - - - -## Reference - -- [Authentication](authentication.mdx) -- [Certificates](certificates.mdx) -- [XAdES API](../reference/api/xades.md) diff --git a/docs/guides/fa3-builder.md b/docs/guides/fa3-builder.md new file mode 100644 index 0000000..f4949ea --- /dev/null +++ b/docs/guides/fa3-builder.md @@ -0,0 +1,222 @@ +--- +title: FA(3) Builder +description: Build FA(3) invoice XML with typed SDK helpers. +--- + +# FA(3) Builder + +Use `ksef2.fa3` to build typed FA(3) invoices inside the SDK. +Import the builder and the commonly used enums directly from that namespace. + +```python +from datetime import date +from decimal import Decimal + +from ksef2.fa3 import FA3InvoiceBuilder, VatRate + +invoice = ( + FA3InvoiceBuilder() + .header(system_info="my app") + .seller( + name="ACME S.A.", + tax_id="1234567890", + country_code="PL", + address_line_1="ul. Przykladowa 123", + address_line_2="Warszawa", + ) + .buyer( + name="XYZ GmbH", + country_code="DE", + address_line_1="Unter den Linden 1", + address_line_2="10115 Berlin", + ) + .standard() + .issue_place("Warszawa") + .issue_date(date(2026, 3, 29)) + .invoice_number("FV/2026/03/0001") + .rows() + .add_line( + name="Consulting service", + supply_date=date(2026, 3, 29), + unit_of_measure="h", + quantity=Decimal("10"), + unit_price_net=Decimal("100.00"), + vat_rate=VatRate.VAT_23, + ) + .done() + .done() + .build() +) +``` + +`build()` returns a `KsefInvoice` domain model. +If you want XML directly, call `to_xml()` on the builder instead. + +## Import + +```python +from ksef2.fa3 import FA3InvoiceBuilder, KsefInvoice, VatRate +``` + +## Builder Flow + +The builder uses a nested DSL. +Each `.done()` returns to the previous level. + +Typical flow: + +1. Create the root builder. +2. Fill in `header(...)`, `seller(...)`, and `buyer(...)`. +3. Choose the invoice kind. +4. Add nested sections such as `rows()`, `payment()`, `transaction()`, or `annotations()`. +5. Finish with `build()`, `to_spec()`, or `to_xml()`. + +Example with payment and annotations: + +```python +from datetime import date +from decimal import Decimal + +from ksef2.fa3 import FA3InvoiceBuilder, VatRate + +builder = FA3InvoiceBuilder() +_ = ( + builder.header(system_info="my app") + .seller( + name="ACME S.A.", + tax_id="1234567890", + country_code="PL", + address_line_1="ul. Przykladowa 123", + ) + .buyer( + name="XYZ GmbH", + country_code="DE", + address_line_1="Unter den Linden 1", + ) + .standard() + .issue_date(date(2026, 3, 29)) + .invoice_number("FV/2026/03/0001") + .rows() + .add_line( + name="Consulting service", + quantity=Decimal("1"), + unit_of_measure="h", + unit_price_net=Decimal("100.00"), + vat_rate=VatRate.VAT_23, + ) + .done() + .payment() + .via("bank_transfer") + .due_on(date(2026, 4, 12)) + .bank_account("PL10101010101010101010101010") + .done() + .annotations() + .split_payment() + .done() + .done() +) + +invoice = builder.build() +xml_text = builder.to_xml() +``` + +## Invoice Kinds + +Use one of these body selectors: + +- `standard()` +- `simplified()` +- `correction()` +- `advance()` +- `settlement()` +- `correction_advance()` +- `correction_settlement()` + +## Output Forms + +The same builder can produce the invoice in three forms: + +- `build()` -> `KsefInvoice` +- `to_spec()` -> FA(3) `Faktura` model +- `to_xml()` -> XML string + +```python +invoice = builder.build() +spec = builder.to_spec() +xml_text = builder.to_xml() +``` + +## Saving Drafts + +You can persist the current builder state as a `KsefInvoiceDraft` and load it back later. +This works for incomplete drafts as well as fully populated invoices. + +```python +from ksef2.fa3 import FA3InvoiceBuilder, KsefInvoiceDraft + +builder = FA3InvoiceBuilder() +_ = ( + builder.header(system_info="my app") + .seller( + name="ACME S.A.", + tax_id="1234567890", + country_code="PL", + address_line_1="ul. Przykladowa 123", + ) +) + +draft = builder.dump_state() +json_text = builder.dump_state_json(indent=2) + +same_draft = KsefInvoiceDraft.model_validate_json(json_text) +restored_builder = FA3InvoiceBuilder.from_state(same_draft) +restored_from_json = FA3InvoiceBuilder.from_state_json(json_text) +``` + +If you already have a built invoice and want to reopen it in the builder, use `from_invoice(...)`: + +```python +invoice = builder.build() +restored_builder = FA3InvoiceBuilder.from_invoice(invoice) +``` + +## Sending The Built Invoice + +You can send the generated XML through an online session: + +```python +from ksef2 import FormSchema + +with auth.online_session(form_code=FormSchema.FA3) as session: + result = session.send_invoice(invoice_xml=builder.to_xml().encode("utf-8")) + print(result.reference_number) +``` + +## Common Types + +`ksef2.fa3` also re-exports the models and enums that are commonly used with the builder: + +- `KsefInvoice` +- `KsefInvoiceDraft` +- `InvoiceHeader` +- `InvoiceEntity` +- `InvoiceAddress` +- `InvoiceThirdParty` +- `ContactInfo` +- `VatRate` +- `VatClassification` +- `VatTreatment` +- `SaleCategory` +- `TaxRegime` +- `InvoiceSummaryOverrides` + +## Examples + +- [`scripts/examples/invoices/build_fa3_invoice_builder.py`](../../scripts/examples/invoices/build_fa3_invoice_builder.py) +- [`scripts/examples/invoices/build_fa3_invoice_sample_1.py`](../../scripts/examples/invoices/build_fa3_invoice_sample_1.py) +- [`tests/integration/builders/fa3/test_standard_invoice.py`](../../tests/integration/builders/fa3/test_standard_invoice.py) + +## Related + +- [Invoices](invoices.md) +- [Authentication](authentication.md) diff --git a/docs/pl/concepts/authentication-methods.mdx b/docs/pl/concepts/authentication-methods.mdx new file mode 100644 index 0000000..2f45934 --- /dev/null +++ b/docs/pl/concepts/authentication-methods.mdx @@ -0,0 +1,221 @@ +--- +title: Metody uwierzytelniania +description: Zrozum uwierzytelnianie tokenem KSeF, XAdES, certyfikatem TEST i profilami w ksef2. +--- + +import { Aside, CardGrid, LinkCard, Steps } from '@astrojs/starlight/components'; + +Uwierzytelnianie zamienia głównego `Client` albo `AsyncClient` w klienta +uwierzytelnionego dla jednego kontekstu KSeF. Klient główny wybiera środowisko +i posiada zasoby transportu. Metoda uwierzytelniania potwierdza, kto może +działać w wybranym kontekście. + +KSeF rozdziela dwa pojęcia, które łatwo pomylić: + +| Pojęcie | Znaczenie w KSeF | Powierzchnia SDK | +| --- | --- | --- | +| Kontekst logowania | Podatnik albo podmiot, w imieniu którego będą wykonywane operacje. | `nip` oraz `context_type`, na przykład `context_type="nip"`. | +| Podmiot uwierzytelniający | Token albo tożsamość certyfikatowa, która potwierdza prawo wejścia w ten kontekst. | `ksef_token`, albo `cert` i `private_key`. | + +Po udanym uwierzytelnieniu SDK zwraca `AuthenticatedClient`. Ten klient +przechowuje access i refresh tokeny KSeF oraz udostępnia chronione gałęzie: +sesje, faktury, tokeny, uprawnienia, certyfikaty, limity i historię sesji. + +```text +Client(Environment.TEST) + -> client.authentication + -> with_token(), with_xades(), with_test_certificate() albo with_profile() + -> AuthenticatedClient dla jednego kontekstu KSeF + -> sesje online, sesje batch, faktury + -> tokeny, uprawnienia, certyfikaty, limity +``` + +## Metody KSeF i pomocniki SDK + +Na poziomie API KSeF uwierzytelnianie ma dwie główne rodziny: + +| Rodzina KSeF | Co trafia do KSeF | Pomocnik SDK | +| --- | --- | --- | +| Uwierzytelnianie XAdES | Podpisany dokument XML `AuthTokenRequest`. | `with_xades()` | +| Uwierzytelnianie tokenem KSeF | Token KSeF zaszyfrowany aktualnym certyfikatem do szyfrowania tokenów. | `with_token()` | + +SDK dodaje dwie warstwy wygody: + +| Pomocnik SDK | Co dodaje | +| --- | --- | +| `with_test_certificate()` | Generuje tymczasowy materiał certyfikatu TEST, a potem używa przepływu XAdES. | +| `with_profile()` | Czyta lokalną konfigurację profilu `ksef2-cli` i wybiera token, certyfikat TEST, PEM XAdES albo PKCS#12 XAdES. | + + + +## Wybierz metodę + +| Metoda | Kiedy jej używać | Co robi SDK | +| --- | --- | --- | +| `with_test_certificate()` | Pracujesz z `Environment.TEST` i chcesz najmniej konfiguracji. | Generuje certyfikat TEST z NIP-u i uwierzytelnia się przez XAdES. | +| `with_token()` | Aplikacja ma już token KSeF dla docelowego kontekstu. | Pobiera certyfikat KSeF do szyfrowania tokenów, szyfruje token ze znacznikiem czasu challenge, rozpoczyna uwierzytelnianie tokenem, odpytuje status i pobiera tokeny. | +| `with_xades()` | Aplikacja ma certyfikat i klucz prywatny dostępne w Pythonie. | Buduje XML challenge KSeF, podpisuje go XAdES, wysyła podpisany XML, odpytuje status i pobiera tokeny. | +| `with_profile()` | Chcesz, aby kod SDK i `ksef2-cli` współdzieliły nazwane lokalne ustawienia. | Rozwiązuje wybrany profil i wywołuje odpowiednią metodę SDK. | + + + + + + +## Podmiot i kontekst przy XAdES + +Przy uwierzytelnianiu XAdES SDK buduje i podpisuje żądanie XML dla kontekstu +NIP przekazanego do `with_xades()`. KSeF odczytuje podmiot uwierzytelniający z +certyfikatu użytego do podpisu: na przykład pieczęci firmowej, certyfikatu +osobistego z NIP-em albo PESEL-em, certyfikatu KSeF do uwierzytelniania lub +certyfikatu dopuszczonego przez uprawnienia na odcisk palca. + +To znaczy, że `nip` jest kontekstem logowania, a nie zawsze identyfikatorem +wbudowanym w certyfikat podpisujący. Osoba może uwierzytelnić się w kontekście +firmy, jeśli KSeF widzi aktywne uprawnienia tej osoby albo certyfikatu. + + + +## Profile są lokalną konfiguracją uwierzytelniania + +`with_profile()` nie jest osobnym typem credentiala. To warstwa konfiguracji +nad tymi samymi metodami uwierzytelniania: + +| Typ auth w profilu | Bezpośrednia metoda SDK | +| --- | --- | +| `token` | `with_token()` | +| `test_certificate` | `with_test_certificate()` | +| `xades_pem` | `with_xades()` z plikami certyfikatu i klucza PEM | +| `xades_p12` | `with_xades()` z archiwum PKCS#12/PFX | + +Profil przechowuje niesekretne ustawienia, takie jak środowisko, NIP, typ +uwierzytelniania, ścieżki certyfikatów, ustawienia pollingu i nazwę zmiennej +środowiskowej zawierającej sekret. Nie powinien przechowywać wartości tokenów, +haseł kluczy prywatnych ani haseł PKCS#12. + +Kolejność wyboru profilu jest taka sama w SDK i CLI: + +1. Jawna nazwa profilu przekazana do `with_profile("name")`. +2. `KSEF2_PROFILE`. +3. `active_profile` w lokalnej konfiguracji profili. + + + +Środowisko klienta głównego musi zgadzać się ze środowiskiem wybranego profilu. +Profil `test` używaj z `Client(Environment.TEST)`, profil `demo` z +`Client(Environment.DEMO)`, a profil `production` z +`Client(Environment.PRODUCTION)`. + +## Cykl uwierzytelniania + + + +1. Klient główny pobiera challenge z wybranego środowiska KSeF. + +2. Wybrana metoda potwierdza tożsamość dla żądanego kontekstu. + + Uwierzytelnianie tokenem szyfruje token KSeF razem ze znacznikiem czasu z + challenge. Uwierzytelnianie XAdES podpisuje XML challenge. Certyfikat TEST + najpierw generuje materiał certyfikatu TEST. Profil odczytuje jedną z tych + ścieżek z lokalnej konfiguracji. + +3. KSeF rozpoczyna asynchroniczną operację uwierzytelniania. + + SDK trzyma tymczasowy token operacji i numer referencyjny wewnętrznie, gdy + odpytuje status. + +4. SDK zamienia zakończoną operację na access i refresh tokeny. + +5. Zwrócony `AuthenticatedClient` używa tych tokenów w chronionych operacjach + SDK. + + + +Klient asynchroniczny ma te same nazwy metod na `AsyncClient`; wywołuj je z +`await`. Wybór między tokenem, certyfikatem TEST, XAdES i profilem pozostaje +taki sam. + +## Co chronić + +| Wartość | Dlaczego jest ważna | +| --- | --- | +| Token KSeF | Może uwierzytelnić się w swoim kontekście do czasu cofnięcia albo wygaśnięcia. | +| Klucz prywatny i hasło certyfikatu | Mogą potwierdzić tożsamość używaną przez uwierzytelnianie XAdES. | +| Access token | Autoryzuje chronione wywołania SDK, dopóki jest ważny. | +| Refresh token | Może pobrać nowy access token, dopóki jest ważny. | +| Stan wznowienia uwierzytelnienia | Zawiera access i refresh tokeny potrzebne do odtworzenia klienta uwierzytelnionego. | +| Zserializowany stan sesji | Może zawierać materiał szyfrowania sesji i URL-e uploadu potrzebne do wznowienia przepływu. | + +Trzymaj sekrety w zmiennych środowiskowych, menedżerach sekretów albo +bezpiecznym magazynie aplikacji. Pliki profili ograniczaj do niesekretnych +domyślnych ustawień i nazw zmiennych środowiskowych, które zawierają sekrety. + +## Uwierzytelnienie nie jest autoryzacją + +Uwierzytelnienie potwierdza tożsamość dla kontekstu. Uprawnienia nadal +określają, które operacje ta tożsamość może wykonać. Credential może +uwierzytelnić się poprawnie, a potem nie mieć prawa do wysyłki faktur, +metadanych, zarządzania tokenami albo nadawania uprawnień. + + + +## Powiązane strony + + + + + + + + + diff --git a/docs/pl/concepts/certificates.mdx b/docs/pl/concepts/certificates.mdx new file mode 100644 index 0000000..5b0a6a1 --- /dev/null +++ b/docs/pl/concepts/certificates.mdx @@ -0,0 +1,116 @@ +--- +title: Certyfikaty +description: Zrozum role certyfikatów w uwierzytelnianiu KSeF, rejestracji, trybie offline i publicznym szyfrowaniu. +--- + +import { Aside, LinkCard, Steps } from '@astrojs/starlight/components'; + +KSeF używa słowa certyfikat w kilku różnych miejscach. Łączy je kryptografia, +ale w SDK nie pełnią tej samej roli. + +| Rola certyfikatu | Kto go posiada | Powierzchnia SDK | Cel | +| --- | --- | --- | --- | +| Materiał podpisu uwierzytelniania | Aplikacja, użytkownik, HSM, magazyn certyfikatów albo helper TEST. | `client.authentication.with_xades()` i `ksef2.xades` | Podpisuje `AuthTokenRequest` używany do uwierzytelnienia. | +| Certyfikat wydany przez KSeF | KSeF wydaje go po wniosku rejestracyjnym. Przechowujesz go z kluczem prywatnym. | `auth.certificates` | Daje podmiotowi uwierzytelniającemu certyfikat KSeF do uwierzytelniania albo trybu offline. | +| Publiczny certyfikat szyfrowania | KSeF go publikuje. SDK go odczytuje. | `client.encryption` i przepływy wysokopoziomowe | Szyfruje payload uwierzytelniania tokenem, klucze sesji i klucze eksportu dla KSeF. | + +Rozdzielenie tych ról zapobiega dwóm częstym błędom projektowym: traktowaniu +certyfikatu tak, jakby niósł uprawnienia KSeF, albo traktowaniu rejestracji +certyfikatu tak, jakby KSeF miał otrzymać klucz prywatny. + + + +## Tożsamość i uprawnienia + +Certyfikat KSeF jest nośnikiem tożsamości podmiotu uwierzytelniającego. Zwykle +jest powiązany z NIP-em, PESEL-em albo odciskiem palca certyfikatu. Sam nie +jest przypisany do kontekstu podatnika i nie zawiera zestawu uprawnień używanego +przez operacje KSeF. + +Uprawnienia są oceniane osobno. Certyfikat może potwierdzić, kto się +uwierzytelnia, a KSeF nadal sprawdza, czy ten podmiot ma prawo działać w +wybranym kontekście logowania. + +## Typy certyfikatów + +Certyfikaty wydawane przez KSeF mają jeden typ: + +| Wartość SDK | Znaczenie | +| --- | --- | +| `authentication` | Może być używany jako materiał certyfikatu do uwierzytelniania KSeF. | +| `offline` | Używany do identyfikacji wystawiania faktur offline; nie jest credentialem logowania. | + +Jeden wydany certyfikat nie łączy obu ról. Wnioskuj o typ pasujący do +przepływu, który budujesz. + +## Model rejestracji + +Rejestracja certyfikatu jest przepływem uwierzytelnionym, ale nie jest tylko +kolejnym formularzem administracyjnym. KSeF wyprowadza dane podmiotu do +rejestracji z certyfikatu użytego do uwierzytelnienia XAdES. CSR musi użyć tych +wartości; ich zmiana powoduje odrzucenie wniosku przez KSeF. + +```python +limits = auth.certificates.get_limits() +print(limits.can_request, limits.enrollment_remaining) + +subject = auth.certificates.get_enrollment_data() +print(subject.common_name, subject.country_name) +``` + +SDK udostępnia cykl życia jako metody wysokiego poziomu, ale klucz prywatny i +CSR nadal pochodzą z Twoich narzędzi certyfikatowych: + + + +1. Uwierzytelnij się materiałem certyfikatu XAdES. +2. Odczytaj limity certyfikatów i dane podmiotu do rejestracji. +3. Wygeneruj klucz prywatny i CSR poza SDK. +4. Wyślij CSR przez `auth.certificates.enroll()`. +5. Odpytuj status rejestracji, aż KSeF zwróci numer seryjny certyfikatu. +6. Pobierz wydany certyfikat i zapisz go razem z kluczem prywatnym. + + + + + +## Relacja do XAdES i szyfrowania + +XAdES używa materiału certyfikatu do podpisania XML uwierzytelniającego. +Rejestracja certyfikatów zarządza certyfikatami wydawanymi przez KSeF. Publiczne +certyfikaty szyfrowania są jeszcze czymś innym: to publiczne klucze KSeF używane +przez SDK do szyfrowania kluczy payloadów. + +Jeśli problem dotyczy kluczy prywatnych, CSR, numerów seryjnych wydanych +certyfikatów albo certyfikatów offline, należy do zarządzania certyfikatami. +Jeśli dotyczy `public_key_id`, szyfrowania tokenu, szyfrowania sesji albo +deszyfrowania paczki eksportu, należy do modelu szyfrowania. + +## Powiązane strony + + + + + diff --git a/docs/pl/concepts/clients-and-lifecycle.mdx b/docs/pl/concepts/clients-and-lifecycle.mdx new file mode 100644 index 0000000..8bcdd11 --- /dev/null +++ b/docs/pl/concepts/clients-and-lifecycle.mdx @@ -0,0 +1,147 @@ +--- +title: Klienci i cykl życia +description: Zrozum klientów głównych, klientów uwierzytelnionych, uchwyty sesji i własność zasobów w ksef2. +--- + +import { Aside, LinkCard } from '@astrojs/starlight/components'; + +SDK ma niewielką hierarchię obiektów. Każdy obiekt oznacza inną granicę cyklu +życia, a nie tylko inną przestrzeń nazw. + +```text +Client albo AsyncClient + -> publiczne gałęzie klienta głównego + -> authentication + -> AuthenticatedClient dla jednego kontekstu KSeF + -> sesje, faktury, tokeny, uprawnienia, certyfikaty, limity + -> sesja online, sesja batch, uchwyt eksportu +``` + +Klient główny wybiera środowisko KSeF i posiada zasoby transportu. Klient +uwierzytelniony niesie dostęp do jednego kontekstu. Klienci sesji i uchwyty +eksportu są węższymi obiektami przepływu i nie powinny być używane jako ogólne +punkty wejścia SDK. + +## Model obiektów + +| Obiekt | Co posiada | Co można użyć ponownie | +| --- | --- | --- | +| `Client` / `AsyncClient` | Środowisko, transport HTTP, retry, TLS/proxy i cache publicznych certyfikatów. | Użyj ponownie, gdy operacje mają to samo środowisko i politykę transportu. | +| Klient uwierzytelniony | Tokeny dostępu i odświeżania dla jednego kontekstu logowania. | Użyj ponownie, gdy operacje należą do tego samego kontekstu podatnika i stanu credentiali. | +| Klient sesji online | Jeden zdalny numer referencyjny sesji online oraz materiał szyfrowania sesji. | Użyj dla jednego przepływu wysyłki, potem zamknij sesję. | +| Klient albo stan sesji batch | Jeden przepływ uploadu batch oraz materiał szyfrowania paczki. | Użyj do uploadu, zamknięcia, pollingu statusu i pobrania UPO. | +| Uchwyt eksportu | Numer referencyjny eksportu oraz lokalny materiał AES potrzebny do odszyfrowania części paczki. | Trzymaj tylko do czasu pobrania i zapisania paczki eksportu. | + +Główna zasada cyklu życia jest prosta: używaj ponownie klientów głównych i +uwierzytelnionych, gdy środowisko i kontekst się zgadzają; sesje i eksporty +traktuj jako uchwyty jednego przepływu. + +## Klienci główni + +Klient główny jest granicą zasobów HTTP zarządzanych przez SDK. Udostępnia +publiczne gałęzie, które nie wymagają uwierzytelnienia, na przykład odczyt +certyfikatów szyfrowania i dostawców PEPPOL. Udostępnia też `authentication`, +czyli wejście do uwierzytelnionego kontekstu KSeF. + +Twórz klienta głównego dla jednego środowiska: TEST, DEMO albo PRODUCTION. Nie +przełączaj jednego klienta między środowiskami. Jeżeli aplikacja rozmawia z +więcej niż jednym środowiskiem, każde środowisko powinno mieć osobnego klienta +głównego i osobny cykl życia. + +Zamknięcie klienta głównego zwalnia zasoby HTTP, gdy należą one do SDK. Jeżeli +przekazujesz do SDK własnego klienta `httpx`, aplikacja, która go utworzyła, +wciąż odpowiada za jego zamknięcie. + +## Klienci uwierzytelnieni + +Uwierzytelnienie zmienia klienta głównego w klienta uwierzytelnionego dla +jednego kontekstu KSeF. Ten kontekst to zwykle NIP podatnika plus credential, +który potwierdza prawo do działania w tym kontekście. + +Klient uwierzytelniony jest punktem wejścia do gałęzi chronionych: + +| Gałąź | Rodzaj przepływu | +| --- | --- | +| `online_session` | Interaktywna wysyłka faktur. | +| `batch` | Przygotowanie, upload, status i UPO paczki batch. | +| `invoices` | Metadane, bezpośrednie pobrania, eksporty i pobieranie paczek eksportu. | +| `tokens` | Generowanie, lista, status i unieważnianie tokenów KSeF. | +| `permissions` | Nadawanie, zapytania, cofanie i status operacji uprawnień. | +| `certificates` | Enrollment, pobieranie, limity i unieważnianie certyfikatów KSeF. | +| `limits` | Odczyt albo zmiana limitów kontekstu, podmiotu i API. | +| `sessions` / `invoice_sessions` | Inspekcja sesji uwierzytelnienia i sesji faktur. | + +Klienta uwierzytelnionego można używać ponownie między funkcjami workflow, gdy +należą do tego samego kontekstu KSeF. Przekazywanie go do kodu przepływu trzyma +setup transportu i credentiali poza logiką operacji biznesowej. + +## Uchwyty sesji + +Klienci sesji online i batch nie są ogólnymi klientami uwierzytelnionymi. Są +uchwytami do jednej zdalnej referencji sesji KSeF i lokalnego materiału +szyfrowania używanego przez tę sesję. + +Sesja online powinna zostać zamknięta, gdy nie będą wysyłane kolejne faktury. +Zamknięcie sesji mówi KSeF, że etap wysyłki jest zakończony i pozwala przejść +do finalnego statusu oraz dostępności UPO. + +Przepływ batch też ma granicę zamknięcia. KSeF zaczyna przetwarzanie dopiero po +wysłaniu zaszyfrowanych części paczki i zamknięciu sesji batch. + + + +## Sync i async + +`Client` i `AsyncClient` udostępniają ten sam model gałęzi. Klient async zmienia +styl wywołań: operacje sieciowe używają `await`, a granice cyklu życia używają +async context managerów. Nie zmienia to modelu workflow KSeF. + +Wybierz klienta sync dla skryptów, narzędzi CLI, synchronicznych workerów i +aplikacji, które nie mają własnej pętli zdarzeń. Wybierz klienta async, gdy +aplikacja już jest asynchroniczna, na przykład async web service albo async +worker. + +## Co zapisywać + +Nie zapisuj klientów głównych, klientów uwierzytelnionych ani obiektów sesji. +Zapisuj identyfikatory i artefakty: + +- środowisko i nazwę profilu; +- identyfikator kontekstu logowania; +- numer referencyjny sesji; +- numer referencyjny faktury; +- numer KSeF; +- referencję eksportu i chroniony materiał uchwytu eksportu, dopóki eksport + jest pobierany; +- bajty XML UPO albo miejsce ich zapisu; +- ostatni zaobserwowany status dla supportu i audytu. + +Obiekty klientów są zasobami procesu. Referencje i artefakty są stanem +aplikacji. + +## Powiązane strony + + + + + diff --git a/docs/pl/concepts/encryption.mdx b/docs/pl/concepts/encryption.mdx new file mode 100644 index 0000000..d0f5f19 --- /dev/null +++ b/docs/pl/concepts/encryption.mdx @@ -0,0 +1,97 @@ +--- +title: Szyfrowanie +description: Zrozum, jak ksef2 używa publicznych certyfikatów KSeF do szyfrowania tokenów, sesji i eksportów. +--- + +import { Aside, LinkCard } from '@astrojs/starlight/components'; + +KSeF wymaga, aby część materiału payloadów była zaszyfrowana zanim trafi do API. +SDK ukrywa tę mechanikę w zwykłych przepływach, ale warto wiedzieć, który klucz +jest chroniony i co trzeba zachować, aby kontynuować przepływ. + +Publiczne certyfikaty używane do tego szyfrowania są publikowane przez KSeF. +Aplikacja pobiera publiczny materiał certyfikatu, szyfruje lokalne sekrety dla +KSeF i wysyła `public_key_id`, który wskazuje użyty klucz publiczny KSeF. + +## Gdzie pojawia się szyfrowanie + +| Przepływ | Lokalny sekret | Co otrzymuje KSeF | +| --- | --- | --- | +| Uwierzytelnianie tokenem | Token KSeF razem ze znacznikiem czasu challenge. | Zaszyfrowany payload uwierzytelniania tokenem i odpowiadający `public_key_id`. | +| Sesja online | Klucz AES i IV wygenerowane dla XML faktury. | Zaszyfrowany klucz sesji, IV i zaszyfrowane payloady faktur. | +| Sesja batch | Klucz AES i IV dla paczki batch. | Zaszyfrowany materiał klucza batch i deklaracje uploadu. | +| Eksport faktur | Klucz AES i IV dla paczki eksportu. | Zaszyfrowany materiał klucza eksportu; SDK później używa lokalnego materiału do odszyfrowania pobranych części. | + + + +## Model publicznego certyfikatu + +KSeF publikuje publiczne certyfikaty klucza z metadanymi, które mówią klientom, +do czego służy klucz i kiedy jest ważny. + +| Pole SDK | Znaczenie | +| --- | --- | +| `certificate` | Materiał certyfikatu Base64 używany przez warstwę kryptograficzną SDK. | +| `certificate_id` | Identyfikator obiektu certyfikatu. | +| `public_key_id` | Identyfikator klucza publicznego; odsyłany do KSeF po szyfrowaniu. | +| `valid_from` / `valid_to` | Okno ważności używane przy decyzjach cache i odświeżania. | +| `usage` | `ksef_token_encryption` albo `symmetric_key_encryption`. | + +```python +certificates = client.encryption.get_certificates( + usage=["symmetric_key_encryption"], +) + +for certificate in certificates: + print(certificate.public_key_id, certificate.valid_to, certificate.usage) +``` + +## Rotacja i cache + +Traktuj publiczne certyfikaty szyfrowania jak rotującą infrastrukturę. KSeF może +opublikować nowy certyfikat dla tego samego klucza albo obrócić na nowy klucz. +Nowy certyfikat może zmienić `certificate_id`; nowy klucz zmienia +`public_key_id`. + +Cache certyfikatów jest przydatny, jeśli poprawia czas startu, ale odświeżaj go +zanim długotrwałe workery otworzą sesje, zaplanują eksporty albo rozpoczną +uwierzytelnianie tokenem. Nie zapisuj treści certyfikatów na stałe w kodzie +aplikacji. + +## Co zapisywać + +Dla zwykłej wysyłki faktur zapisuj identyfikatory biznesowe, referencje KSeF, +dane UPO i finalne artefakty. Nie zapisuj niskopoziomowych szczegółów +szyfrowania, chyba że inny proces musi wznowić sesję. + +Dla eksportów przechowuj `ExportHandle`, aż paczka zostanie pobrana. Zawiera +lokalny materiał deszyfrujący potrzebny do części eksportu. Dla wznawianych +sesji online albo batch zapisuj serializowany stan sesji zwrócony przez SDK +zamiast ręcznie rekonstruować klucze. + +## Powiązane strony + + + + + diff --git a/docs/pl/concepts/environments.mdx b/docs/pl/concepts/environments.mdx new file mode 100644 index 0000000..4c77723 --- /dev/null +++ b/docs/pl/concepts/environments.mdx @@ -0,0 +1,96 @@ +--- +title: Środowiska +description: Zrozum wybór środowisk TEST, DEMO i PRODUCTION w ksef2. +--- + +import { Aside, LinkCard } from '@astrojs/starlight/components'; + +Przepływy KSeF działają wobec jawnie wybranego środowiska. Wybierasz je przy +tworzeniu klienta głównego, a dane dostępowe, profile, zapisany stan sesji i +URL-e pobierania powinny pozostać przypisane do tego środowiska. + +| Środowisko | Oficjalna rola | Bazowy URL | Typowe użycie | +| --- | --- | --- | --- | +| `Environment.TEST` | Środowisko testowe / release candidate | `https://api-test.ksef.mf.gov.pl/v2` | Lokalny development, CI, certyfikaty TEST generowane przez SDK, dane TEST. | +| `Environment.DEMO` | Środowisko przedprodukcyjne / demo | `https://api-demo.ksef.mf.gov.pl/v2` | Testy integracyjne z zachowaniem podobnym do produkcji i nieprodukcyjnymi credentialami. | +| `Environment.PRODUCTION` | Środowisko produkcyjne | `https://api.ksef.mf.gov.pl/v2` | Rzeczywiste przepływy podatnika i produkcyjne credentiale. | + + + +## Oficjalne granice środowisk + +TEST jest celowo luźniejszy niż DEMO i PRODUCTION. Obsługuje przepływy danych +testowych, materiał certyfikatu testowego generowany przez SDK i scenariusze z +certyfikatami self-signed. Dane TEST nie są prywatnym archiwum produkcyjnym, więc +nie używaj tam rzeczywistych danych faktur. + +DEMO jest bliżej zachowania produkcyjnego, ale nadal nie jest środowiskiem +produkcyjnych faktur. PRODUCTION jest jedynym środowiskiem dla rzeczywistych +przepływów podatnika. URL-e zwracane przez KSeF, na przykład linki do paczek, +należą do tego samego hosta środowiska, które je wytworzyło. + +Dostępność schematów faktur też zależy od środowiska: + +| Środowisko | Format faktur ustrukturyzowanych | +| --- | --- | +| TEST | `FA(2)`, `FA(3)`, `FA_PEF(3)`, `FA_KOR_PEF(3)` | +| DEMO | `FA(3)`, `FA_PEF(3)`, `FA_KOR_PEF(3)` | +| PRODUCTION | `FA(3)`, `FA_PEF(3)`, `FA_KOR_PEF(3)` | + +## Co kontroluje środowisko + +Środowisko wybiera bazowy URL API KSeF oraz granicę operacyjną dla credentiali, +profili, certyfikatów, danych testowych, referencji sesji, numerów faktur i +eksportów. + +```python +from ksef2 import Client, Environment + +client = Client(Environment.TEST) +``` + +`client.testdata` jest dostępne tylko w `Environment.TEST`. Helper +uwierzytelniania certyfikatem TEST generowanym przez SDK również działa tylko w +TEST. + +## Granice bezpieczeństwa + +Credentiale są przypisane do środowiska. Profil utworzony dla PRODUCTION nie +powinien być wybrany przez klienta TEST, a `with_profile()` odrzuca taki +mismatch. + +Zapisany stan oznaczaj środowiskiem, które go wytworzyło: + +- nazwa profilu uwierzytelniania; +- identyfikator podatnika albo kontekstu; +- referencja sesji online albo batch; +- numer referencyjny faktury; +- numer KSeF; +- numer referencyjny eksportu. + +## Powiązane strony + + + + + diff --git a/docs/pl/concepts/glossary.mdx b/docs/pl/concepts/glossary.mdx new file mode 100644 index 0000000..a5e00c9 --- /dev/null +++ b/docs/pl/concepts/glossary.mdx @@ -0,0 +1,129 @@ +--- +title: Słownik +description: Krótkie definicje pojęć KSeF, SDK, faktur, podpisu i przepływów używanych w dokumentacji ksef2. +--- + +import { Aside } from '@astrojs/starlight/components'; + + + +## Access token + +Token bearer uzyskany podczas uwierzytelniania i używany przez klienta +uwierzytelnionego do wywołań API KSeF. + +## Batch session + +Sesja KSeF używana do uploadu przygotowanych zaszyfrowanych części paczki faktur +i uruchomienia przetwarzania przez zamknięcie sesji. + +## HWM + +High Water Mark. Znacznik KSeF oznaczający, że dane faktur są kompletne do +wartości `permanent_storage_date`, używany przy niezawodnej synchronizacji +przyrostowej. + +## DEMO + +Nieprodukcyjne środowisko KSeF używane do testów integracyjnych z +nieprodukcyjnymi credentialami. + +## Environment + +Wartość SDK wybierająca bazowy URL API KSeF i granicę operacyjną: +`Environment.TEST`, `Environment.DEMO` albo `Environment.PRODUCTION`. + +## Export + +Asynchroniczny przepływ KSeF przygotowujący metadane albo treść faktur pasujące +do filtrów jako zaszyfrowaną paczkę do pobrania. + +## Export handle + +Obiekt SDK zawierający numer referencyjny eksportu i lokalny materiał +deszyfrujący potrzebny do pobrania oraz odszyfrowania paczki. + +## FA(3) + +Schemat formularza faktury reprezentowany przez `FormSchema.FA3` przy otwieraniu +sesji faktur online albo batch. + +## Klient główny + +Nieuwierzytelniony `Client` albo `AsyncClient`, który posiada środowisko, +transport HTTP, publiczne gałęzie odczytowe i punkt wejścia do uwierzytelniania. + +## Klient uwierzytelniony + +Gałąź klienta SDK zwrócona przez uwierzytelnianie. Jest powiązana z jednym +środowiskiem, jednym kontekstem KSeF i parą access/refresh token. + +## Kontekst + +Podatnik albo jednostka reprezentowana podczas uwierzytelniania, zwykle wybrana +przez NIP albo inny obsługiwany identyfikator kontekstu. + +## Metadata + +Wyszukiwalne informacje o fakturze zwracane przez API zapytań. Metadane nie są +dokumentem XML faktury. + +## NIP + +Polski numer identyfikacji podatkowej, często używany jako identyfikator +podatnika albo kontekstu. + +## Numer KSeF + +Identyfikator nadany przez KSeF po skutecznym przetworzeniu faktury. Zapisuj go +do pobierania, uzgodnień, wsparcia i przepływów UPO. + +## Numer referencyjny + +Uchwyt KSeF dla pracy asynchronicznej albo stanowej, takiej jak +uwierzytelnianie, sesje, wysyłki faktur, eksporty, tokeny, operacje uprawnień i +rejestracja certyfikatów. + +## `permanent_storage_date` + +Data metadanych używana przez trwały zapis KSeF i model synchronizacji High +Water Mark. + +## Podmiot uwierzytelniający + +Osoba, jednostka, fingerprint certyfikatu albo materiał tokenu, który potwierdza +tożsamość przy uwierzytelnianiu do kontekstu KSeF. + +## Online session + +Sesja KSeF używana do wysyłania pojedynczych zaszyfrowanych faktur oraz +sprawdzania ich statusu i UPO w ramach sesji. + +## PEPPOL + +Publiczny obszar lookupu dostawców wystawiony przez klienta głównego. Jest +oddzielny od uwierzytelnionego stanu przetwarzania faktur KSeF. + +## Profile + +Nazwana lokalna konfiguracja credentiali zgodna z `ksef2-cli` i +uwierzytelnianiem SDK przez `with_profile()`. + +## TEST + +Sandboxowe środowisko KSeF używane do lokalnego developmentu, CI, danych TEST i +certyfikatów TEST generowanych przez SDK. + +## UPO + +Urzędowe poświadczenie dostępne po osiągnięciu wymaganego stanu przetworzenia +przez odpowiedni przepływ sesji albo faktury. + +## XAdES + +Format podpisu cyfrowego XML używany w przepływach uwierzytelniania +certyfikatem. diff --git a/docs/pl/concepts/invoice-lifecycle.mdx b/docs/pl/concepts/invoice-lifecycle.mdx new file mode 100644 index 0000000..22b2069 --- /dev/null +++ b/docs/pl/concepts/invoice-lifecycle.mdx @@ -0,0 +1,157 @@ +--- +title: Cykl życia faktury +description: Zrozum drogę faktury od wysyłki XML do numeru KSeF, metadanych, pobrania i UPO. +--- + +import { Aside, LinkCard, Steps } from '@astrojs/starlight/components'; + +W KSeF wysłanie faktury rozpoczyna przetwarzanie, ale nie jest finalnym wynikiem. +API najpierw przyjmuje zaszyfrowane dane faktury do sesji. Dopiero potem KSeF +asynchronicznie waliduje i przetwarza fakturę. + +SDK odzwierciedla ten model: wysyłka najpierw zwraca uchwyt przetwarzania. +Numer KSeF, widoczność w metadanych, pobranie faktury i UPO pojawiają się +później. + +```text +XML FA(3) + -> sesja online albo batch + -> numer referencyjny faktury + -> przetwarzanie KSeF + -> numer KSeF + -> metadane, pobranie, UPO + -> status niepowodzenia faktury +``` + +## Etapy + + + +1. Przygotuj poprawny XML faktury. + + SDK może wysłać bajty z pliku albo bajty wygenerowane przez builder FA(3). + +2. Wyślij XML przez sesję online albo batch. + + Sesje online wysyłają faktury pojedynczo. Sesje batch wgrywają przygotowaną + paczkę ZIP i pokazują wyniki poszczególnych faktur po przetworzeniu paczki. + +3. Zapisz zwrócone referencje. + + Numer referencyjny faktury jest pierwszym trwałym uchwytem dla wysłanej + faktury. Referencja sesji mówi KSeF, gdzie ta faktura została wysłana. + +4. Polluj status sesji albo faktury. + + Poprawna odpowiedź HTTP nie oznacza jeszcze, że faktura ma numer KSeF. + +5. Używaj finalnych identyfikatorów do pobierania i audytu. + + Po poprawnym przetworzeniu numer KSeF staje się stabilnym identyfikatorem do + bezpośredniego pobierania, uzgodnień, metadanych i wielu przepływów wsparcia. + + + +## Send nie oznacza akceptacji + +`send_invoice()` szyfruje XML i wysyła go do otwartej sesji online. Zwraca +`reference_number` faktury. + +`send_invoice_and_wait()` wykonuje tę samą wysyłkę, a potem polluje status +faktury, aż KSeF nada `ksef_number`, osiągnie terminalny błąd albo minie lokalny +timeout. + +```python +sent = session.send_invoice(invoice_xml=xml_bytes) +print(sent.reference_number) + +status = session.get_invoice_status( + invoice_reference_number=sent.reference_number, +) +print(status.status.code, status.ksef_number) +``` + + + +## Identyfikatory, których nie warto mieszać + +| Identyfikator | Gdzie się pojawia | Do czego służy | +| --- | --- | --- | +| Lokalny numer faktury | W XML faktury i lokalnym systemie. | Korelacja biznesowa przed i po przetworzeniu przez KSeF. | +| Numer referencyjny sesji | Po otwarciu sesji online albo batch. | Odnalezienie zdalnego przepływu wysyłki. | +| Numer referencyjny faktury | Odpowiedź z wysyłki i status faktury w sesji. | Polling jednej faktury i pobranie UPO faktury po referencji. | +| Hash faktury | Status faktury w sesji i przygotowanie batch. | Korelacja wyników batch z lokalnymi dokumentami źródłowymi. | +| Numer KSeF | Poprawny status faktury i metadane. | Stabilny identyfikator KSeF do pobierania, uzgodnień i wsparcia. | +| Referencja UPO | Status sesji, gdy dostępne jest zbiorcze UPO. | Pobranie strony UPO dla sesji albo batch. | + +Dla wysyłki batch numer referencyjny faktury może nie być widoczny, dopóki KSeF +nie przetworzy wgranej paczki. Zachowaj metadane przygotowanej paczki, aby +`invoice_hash` i `invoice_file_name` łączyły wyniki KSeF z lokalnymi plikami. + +## Metadane i pobieranie są późniejszymi powierzchniami + +Status sesji mówi, co stało się z wysyłką. Metadane i pobieranie faktury są +powierzchniami odczytu używanymi po przetworzeniu i materializacji faktury przez +KSeF. + +Używaj tych powierzchni do różnych zadań: + +| Powierzchnia | Najlepsze pytanie | +| --- | --- | +| Status faktury w sesji | Czy ta wysłana faktura dostała numer KSeF albo nie przeszła przetwarzania? | +| Zapytanie metadanych | Które przetworzone faktury pasują do filtrów, ról i dat? | +| Bezpośrednie pobranie faktury | Daj XML dla znanego numeru KSeF. | +| Paczka eksportu | Daj większy filtrowany zestaw plików XML faktur. | +| UPO | Daj urzędowe poświadczenie dla przyjętej faktury albo sesji. | + +```python +invoice_xml = auth.invoices.download_invoice(ksef_number=status.ksef_number) +print(len(invoice_xml)) +``` + +## Daty nie są zamienne + +Metadane KSeF wystawiają kilka dat. Wybieraj datę na podstawie przepływu, a nie +etykiety, która wygląda najwygodniej. + +| Data | Znaczenie | +| --- | --- | +| `issue_date` | Data deklarowana na dokumencie faktury. | +| `invoicing_date` | Data otrzymania faktury przez KSeF do przetwarzania. | +| `acquisition_date` | Data udostępnienia faktury stronie w KSeF. | +| `permanent_storage_date` | Data używana przez trwały zapis i przepływy eksportu przyrostowego. | + +Dla synchronizacji przyrostowej preferuj model Permanent Storage opisany na +stronach o zapytaniach i eksporcie. Jest zaprojektowany tak, aby nie pomijać +faktur mimo asynchronicznego przetwarzania KSeF. + +## Powiązane strony + + + + + + diff --git a/docs/pl/concepts/limits.mdx b/docs/pl/concepts/limits.mdx new file mode 100644 index 0000000..4b51a5e --- /dev/null +++ b/docs/pl/concepts/limits.mdx @@ -0,0 +1,97 @@ +--- +title: Limity, polling i ponowienia +description: Zrozum limity kontekstu KSeF, limity podmiotu, rate limity API, deadline pollingu i granice retry. +--- + +import { Aside, LinkCard } from '@astrojs/starlight/components'; + +Limity KSeF są częścią projektu integracji. Wpływają na liczbę faktur w sesji, +częstotliwość pollingu workerów, wybór eksportu zamiast bezpośrednich pobrań i +agresywność retry po throttlingu. + +SDK udostępnia odczyt limitów i administracyjne nadpisania przez `auth.limits`, +ale większość aplikacji powinna traktować limity jako wejście do harmonogramu, +a nie wartości zmieniane podczas zwykłych przepływów biznesowych. + +## Rodziny limitów + +| Rodzina limitów | Model SDK | Na co wpływa | +| --- | --- | --- | +| Limity kontekstu | `ContextLimits` | Liczba faktur i rozmiary payloadów w sesjach online oraz batch. | +| Limity podmiotu | `SubjectLimits` | Limity rejestracji i wydawania certyfikatów dla uwierzytelnionego podmiotu. | +| Rate limity API | `ApiRateLimits` | Wolumen żądań per sekunda, minuta i godzina dla rodzin endpointów. | + +```python +context = auth.limits.get_context_limits() +print(context.online_session.max_invoices) +print(context.batch_session.max_invoice_size_mb) + +rate = auth.limits.get_api_rate_limits() +print(rate.invoice_send.per_minute) +``` + +## Jak limity kształtują przepływy + +Używaj wysyłki online dla niskiego wolumenu albo natychmiastowej wysyłki +faktur. Używaj wysyłki batch, gdy faktury mogą zostać przygotowane jako paczka. +Używaj eksportów, gdy potrzebujesz większego zestawu pobrań; powtarzane +bezpośrednie pobrania i bardzo częste zapytania przyrostowe zwykle są złą +architekturą dla produkcyjnego synchronizatora. + +Dla aplikacji produkcyjnych traktuj KSeF jak zdalny system zapisu, z którego +synchronizujesz dane w kontrolowanych interwałach. Zapisuj metadane i treści +faktur lokalnie, a ekrany użytkownika obsługuj z lokalnego magazynu. + + + +## Deadline pollingu + +Wiele przepływów KSeF jest asynchronicznych. SDK udostępnia helpery takie jak +`send_invoice_and_wait()`, `wait_for_invoice_ready()`, +`wait_for_export_package()`, `wait_for_invoice_download()` i polling aktywacji +tokenu. + +Timeouty tych helperów są limitami oczekiwania na przepływ. Nie są tym samym co +timeouty socketów HTTP. Timeout pollingu oznacza, że oczekiwany stan KSeF nie +został osiągnięty przed lokalnym deadline; nie zawsze oznacza to błąd zdalnej +operacji. + +## Granice retry + +Transport klienta głównego może ponawiać błędy transportu i odpowiedzi KSeF +nadające się do retry. To nie znaczy, że każdą operację można bezpiecznie +powtarzać w ciemno. Wysyłka faktury, nadanie uprawnień, rejestracja certyfikatu, +generowanie tokenu i otwarcie sesji powinny być ponawiane na granicy aplikacji, +z zapisanymi referencjami i zasadami idempotencji właściwymi dla przepływu. + +Gdy KSeF throttluje żądanie, respektuj zwrócone wskazówki retry i zwolnij +harmonogram workera. Jeśli przepływ ma już numer referencyjny KSeF, preferuj +sprawdzenie statusu albo wznowienie z tej referencji zamiast tworzyć operację od +nowa. + +## Powiązane strony + + + + + diff --git a/docs/pl/concepts/overview.mdx b/docs/pl/concepts/overview.mdx new file mode 100644 index 0000000..248eb55 --- /dev/null +++ b/docs/pl/concepts/overview.mdx @@ -0,0 +1,142 @@ +--- +title: Przegląd SDK +description: Zrozum główne obiekty, granice przepływów i pojęcia używane przez ksef2. +--- + +import { Aside, CardGrid, LinkCard } from '@astrojs/starlight/components'; + +ksef2 jest zorganizowane wokół kilku dłużej żyjących punktów wejścia i krótkich +przepływów KSeF. Klient główny `Client` albo `AsyncClient` wybiera środowisko i +posiada konfigurację transportu. Uwierzytelnienie zamienia klienta głównego w +klienta uwierzytelnionego dla jednego kontekstu podatnika. Sesje są otwierane +tylko dla pracy, którą KSeF modeluje jako sesję. + +Użyj tej sekcji, gdy chcesz zrozumieć kształt przepływu KSeF przed pisaniem +kodu. Użyj przewodników praktycznych, gdy znasz już zadanie i potrzebujesz +konkretnych wywołań SDK. + + + + + + + + + + +## Jak SDK odwzorowuje oficjalne API + +Oficjalna dokumentacja API KSeF grupuje pracę w obszary: dostęp, wysyłka +interaktywna, wysyłka wsadowa, status i UPO, pobieranie faktur, tokeny, +uprawnienia, certyfikaty, limity, publiczne certyfikaty szyfrowania i PEPPOL. +SDK zachowuje te granice pojęciowe i wystawia je jako gałęzie klienta głównego +albo klienta uwierzytelnionego. + +| Obszar oficjalnego API | Widok w SDK | +| --- | --- | +| Dostęp | `client.authentication` tworzy klienta uwierzytelnionego dla jednego kontekstu. | +| Wysyłka interaktywna i wsadowa | `auth.online_session()` i `auth.batch_session()` posiadają stan sesji wysyłki. | +| Status i UPO | Klienci sesji oraz `auth.invoice_sessions` sprawdzają zdalne przetwarzanie i potwierdzenia. | +| Pobieranie faktur | `auth.invoices` odpowiada za zapytania metadanych, eksporty, bezpośrednie pobieranie i paczki. | +| Tokeny, uprawnienia, certyfikaty, limity | Gałęzie administracyjne klienta uwierzytelnionego. | +| Dane publiczne | Gałęzie klienta głównego, takie jak `client.encryption`, `client.peppol` i dane TEST. | + +Gałęzie niskopoziomowe `raw` istnieją dla własnych prac protokołowych, +diagnostyki i dokładnego debugowania ładunków KSeF. Są publiczne, ale nie są +normalnym punktem startowym kodu aplikacji. + + + +## Główne pojęcia + +| Pojęcie | Znaczenie w SDK | +| --- | --- | +| Klient główny | Nieuwierzytelniony klient posiadający środowisko i transport HTTP. | +| Klient uwierzytelniony | Gałąź klienta związana z odebranymi tokenami dostępu i odświeżania KSeF dla jednego kontekstu. | +| Sesja online | Krótko żyjący kontekst wysyłki pojedynczych zaszyfrowanych faktur. | +| Sesja wsadowa | Krótko żyjący kontekst wysyłki przygotowanych zaszyfrowanych paczek wsadowych. | +| Usługa faktur | Uwierzytelniona gałąź dla zapytań metadanych, eksportów, bezpośredniego pobierania i pobierania paczek eksportu. | +| Numer referencyjny | Uchwyt zwracany przez KSeF dla sesji, faktur, eksportów, tokenów, uprawnień i innych prac asynchronicznych. | +| Numer KSeF | Identyfikator nadawany po przyjęciu i przetworzeniu faktury przez KSeF. | +| UPO | Urzędowe potwierdzenie dostępne po osiągnięciu wymaganego stanu przetworzenia przez sesję albo fakturę. | + +## Co należy gdzie + +Użyj klienta głównego dla publicznego wyszukiwania i konfiguracji: + +- `client.authentication`, aby rozpocząć uwierzytelnianie. +- `client.encryption`, aby sprawdzić publiczne certyfikaty szyfrowania. +- `client.peppol`, aby wyszukiwać publiczne dane dostawców PEPPOL. +- `client.testdata` tylko w `Environment.TEST`. + +Użyj klienta uwierzytelnionego dla pracy zależnej od kontekstu: + +- `auth.online_session()` i `auth.batch_session()` dla wysyłki. +- `auth.invoices` dla metadanych, eksportów i pobierania. +- `auth.tokens`, `auth.permissions`, `auth.certificates` i `auth.limits` dla przepływów administracyjnych. +- `auth.sessions` i `auth.invoice_sessions` dla zarządzania sesjami i historii. + +## Powiązane strony + + + + + + + + + + diff --git a/docs/pl/concepts/peppol.mdx b/docs/pl/concepts/peppol.mdx new file mode 100644 index 0000000..0d6a6b0 --- /dev/null +++ b/docs/pl/concepts/peppol.mdx @@ -0,0 +1,68 @@ +--- +title: PEPPOL +description: Zrozum lookup dostawców PEPPOL i jego miejsce w SDK. +--- + +import { Aside, LinkCard } from '@astrojs/starlight/components'; + +Lookup dostawców PEPPOL jest publicznym przepływem klienta głównego. Nie wymaga +uwierzytelnionego kontekstu KSeF i sam nie bierze udziału w przetwarzaniu sesji +faktur. + +Używaj go jako danych referencyjnych: do wypełniania wyboru dostawcy, walidacji +identyfikatorów dostawców albo odświeżania lokalnego cache dostawców. Trzymaj +go osobno od wysyłki faktur, zapytań o metadane KSeF, eksportów i statusu UPO. + + + +## Gdzie znajduje się w SDK + +PEPPOL jest wystawiony na kliencie głównym obok innych publicznych gałęzi: + +```python +page = client.peppol.query() + +for provider in page.providers: + print(provider.id, provider.name) +``` + +Odpowiedź jest stronicowana. Użyj `client.peppol.all()`, gdy SDK ma przejść po +stronach za Ciebie: + +```python +providers_by_id = { + provider.id: provider.name + for provider in client.peppol.all() +} +``` + +## Granica produktu + +Rekordy dostawców są danymi referencyjnymi produktu. Cacheuj identyfikatory i +nazwy dostawców zgodnie z wymaganiami świeżości danych, a cache odświeżaj +niezależnie od uwierzytelnionych workerów faktur. + +Łącz ten lookup z przepływami faktur tylko wtedy, gdy Twój produkt ma własny +krok PEPPOL, na przykład wybór identyfikatora dostawcy przez użytkownika przed +budową payloadu specyficznego dla integracji. + +## Powiązane strony + + + + diff --git a/docs/pl/concepts/permissions.mdx b/docs/pl/concepts/permissions.mdx new file mode 100644 index 0000000..930b76d --- /dev/null +++ b/docs/pl/concepts/permissions.mdx @@ -0,0 +1,108 @@ +--- +title: Tokeny i uprawnienia +description: Zrozum tokeny, zakresy uprawnień, nadania, cofnięcia i status operacji w KSeF. +--- + +import { Aside, LinkCard } from '@astrojs/starlight/components'; + +Uwierzytelnienie potwierdza tożsamość. Uprawnienia określają, co ta tożsamość +może zrobić. Tokeny pakują wybrane uprawnienia w wielokrotnego użytku materiał +credential. Credential może uwierzytelnić się poprawnie, a potem nie mieć prawa +do następnej operacji. + +Uprawnienia określają, co podmiot, osoba, jednostka, jednostka UE, autoryzacja +albo subunit mogą zrobić w kontekście KSeF. Wiele zmian uprawnień jest +operacjami asynchronicznymi: wywołanie nadania albo cofnięcia zwraca referencję +operacji, a aplikacja potem sprawdza jej status. + +Kod uprawnień powinien jawnie wskazywać identyfikator celu, zakres uprawnienia i +kontekst. Ułatwia to późniejszy audyt i odtwarzanie po błędach. + +## Tokeny + +`auth.tokens` tworzy, listuje, sprawdza i cofa tokeny KSeF. Generowanie tokenu +zwraca payload tokenu i numer referencyjny. SDK czeka na aktywację tokenu przed +powrotem z `generate()`, ale system produkcyjny nadal powinien zapisać numer +referencyjny i obsłużyć timeout albo błąd aktywacji. + +Wygenerowany token ma stały zestaw uprawnień. Jeśli uprawnienia mają się +zmienić, wygeneruj nowy token i cofnij stary po migracji. Generowanie tokenu +jest dostępne tylko w obsługiwanych kontekstach jednostki, takich jak NIP albo +internal ID, i wymaga wcześniejszego uwierzytelnienia XAdES zanim powstanie +wielokrotnego użytku credential tokenu. + +Tokeny traktuj jako sekrety. Przechowuj wartość tokenu poza kontrolą wersji i +preferuj zmienne środowiskowe albo secret manager. Profile mogą wskazywać +zmienne środowiskowe tokenów, aby CLI i Python używały wspólnej konfiguracji bez +commitowania sekretu. + + + +## Uprawnienia + +API uprawnień jest celowo szczegółowe, bo KSeF odróżnia typy celów i zakresy. +SDK ma osobne metody dla nadań osoby, jednostki, autoryzacji, pośrednich, +subunit i jednostek UE oraz odpowiadające im zapytania i cofnięcia. + +Oficjalny model uprawnień łączy identyfikatory celów, typy uprawnień i reguły +delegowania: + +| Pojęcie | Skutek projektowy | +| --- | --- | +| Identyfikator celu | Użyj właściwego rodzaju identyfikatora, takiego jak NIP, PESEL, fingerprint certyfikatu, NIP VAT UE albo internal ID. | +| Typ uprawnienia | Trzymaj skończone wybory jawnie, na przykład odczyt faktur, zapis faktur, odczyt credentiali, zarządzanie credentialami, zarządzanie subunit, czynności egzekucyjne albo introspekcja. | +| Uprawnienia bezpośrednie i pośrednie | Pytaj i cofaj przez gałąź zgodną ze sposobem nadania uprawnienia. | +| Delegowanie | `can_delegate` ma sens tylko tam, gdzie KSeF pozwala na delegowanie w danej ścieżce uprawnień. | +| Status operacji | Nadania i cofnięcia zwracają referencje operacji; sprawdź status zanim uznasz, że uprawnienie się zmieniło. | + +```python +from ksef2.models import EntityPermission + +operation = auth.permissions.grant_entity( + subject_value="1234567890", + permissions=[EntityPermission(type="invoice_read", can_delegate=False)], + description="Accounting office read access", + entity_name="Accounting Sp. z o.o.", +) + +status = auth.permissions.get_operation_status( + reference_number=operation.reference_number, +) +print(status.status.code, status.status.description) +``` + +Przy błędzie autoryzacji sprawdź: + +- wybrane środowisko; +- uwierzytelniony identyfikator podatnika albo kontekstu; +- nazwę profilu i typ credentiali; +- zestaw uprawnień tokenu; +- identyfikator celu i zakres uprawnień; +- czy operacja nadania albo cofnięcia została zakończona. + +## Powiązane strony + + + + + diff --git a/docs/pl/concepts/querying-and-exports.mdx b/docs/pl/concepts/querying-and-exports.mdx new file mode 100644 index 0000000..57520d0 --- /dev/null +++ b/docs/pl/concepts/querying-and-exports.mdx @@ -0,0 +1,156 @@ +--- +title: Zapytania i eksporty +description: Zrozum metadane faktur, bezpośrednie pobieranie, uchwyty eksportu, paczki i synchronizację przyrostową. +--- + +import { Aside, LinkCard } from '@astrojs/starlight/components'; + +Pobieranie faktur ma trzy różne zadania: + +- zapytania o metadane odpowiadają "jakie faktury istnieją dla tego podmiotu i filtra?"; +- bezpośrednie pobieranie odpowiada "daj mi przetworzony XML dla tego numeru KSeF"; +- eksporty odpowiadają "przygotuj paczkę do pobrania dla większego zestawu faktur". + +KSeF najlepiej traktować jako system wymiany i źródło oficjalnych zapisów +faktur, a nie jako bazę danych pod każdym ekranem aplikacji. W większości +integracji stabilny wzorzec to odpytać albo wyeksportować dane z KSeF, zapisać +wynik lokalnie i zasilać widoki operacyjne z własnego magazynu. + +## Metadane znajdują faktury + +`auth.invoices.query_metadata()` zwraca `QueryInvoicesMetadataResponse`. +Odpowiedź zawiera wiersze `InvoiceMetadata`, a nie XML faktury. + +Metadane są przydatne do list, uzgodnień, pollingu po wysyłce i decyzji, co +pobrać później. Wiersz metadanych może zawierać identyfikatory, daty, dane +stron, kwoty, pola schematu, hashe, flagi załączników i tryb fakturowania. + +Filtr opisuje widok, który KSeF ma przeszukać: + +- `role`, na przykład `seller` albo `buyer`; +- `date_type`, `date_from` i `date_to`; +- opcjonalne `amount_min` i `amount_max` oraz `amount_type`, gdy używasz zakresu kwot; +- identyfikatory takie jak `seller_nip`, `buyer_nip`, `invoice_number` albo `ksef_number`; +- opcjonalne dane faktury, takie jak `invoice_types`, `invoice_schema`, `has_attachment` i `invoicing_mode`. + + + +## Bezpośrednie pobieranie zwraca jeden XML + +`auth.invoices.download_invoice()` pobiera jeden przetworzony dokument XML po +`ksef_number`. + +Ta ścieżka jest celowo wąska. Użyj jej, gdy masz już numer KSeF z wyniku sesji, +zapytania o metadane, przepływu UPO albo własnej bazy. Jeśli faktura została +właśnie zaakceptowana, ale KSeF jeszcze nie udostępnił XML-a, +`auth.invoices.wait_for_invoice_download()` polluje do gotowości dokumentu albo +do lokalnego timeoutu. + +## Eksporty tworzą szyfrowane paczki + +Eksport jest asynchroniczny. Planujesz eksport tym samym kształtem +`InvoicesFilter`, którego używają zapytania o metadane, zachowujesz zwrócony +`ExportHandle`, czekasz na `InvoicePackage`, a potem pobierasz i odszyfrowujesz +części paczki. + +```text +InvoicesFilter + -> auth.invoices.schedule_export() + -> ExportHandle(reference_number, aes_key, iv) + -> auth.invoices.wait_for_export_package() + -> InvoicePackage(parts) + -> auth.invoices.fetch_package() + -> auth.invoices.fetch_package_bytes() +``` + +`fetch_package()` zapisuje odszyfrowane części ZIP na dysku. +`fetch_package_bytes()` zwraca te odszyfrowane części ZIP w pamięci. Obie metody +używają materiału AES zapisanego w `ExportHandle`. + + + +## HWM jest granicą synchronizacji + +Dla automatycznej synchronizacji kluczowym pojęciem jest HWM: High Water Mark. +Gdy KSeF zwraca `permanent_storage_hwm_date`, informuje, że dane faktur w +trwałym zapisie są kompletne do tej granicy. + +Niezawodny kształt synchronizacji wygląda tak: + +```text +ostatni zapisany permanent_storage_date + -> query/export z restrict_to_permanent_storage_hwm_date=True + -> lokalny zapis metadanych i treści faktur + -> zapis permanent_storage_hwm_date jako początku następnego okna +``` + +Użyj `date_type="permanent_storage"`, gdy celem jest synchronizacja +przyrostowa. Użyj `restrict_to_permanent_storage_hwm_date=True`, żeby KSeF +ograniczył wynik do bezpiecznej, zakończonej granicy. + +```python +from ksef2.models import InvoicesFilter + +filters = InvoicesFilter.for_buyer( + date_type="permanent_storage", + date_from=last_synced_at, + restrict_to_permanent_storage_hwm_date=True, +) + +page = auth.invoices.query_metadata(filters=filters) + +# QueryInvoicesMetadataResponse +# { +# "has_more": false, +# "is_truncated": false, +# "permanent_storage_hwm_date": "2026-06-25T10:00:00Z", +# "invoices": [ +# { +# "ksef_number": "1234567890-20260625-...", +# "invoice_number": "FV/42/2026", +# "permanent_storage_date": "2026-06-25T09:58:21Z" +# } +# ] +# } +``` + +Jeśli strona metadanych albo paczka eksportu jest ucięta, kontynuuj od +zwróconej ostatniej daty, na przykład `last_permanent_storage_date`, zamiast +zakładać, że pokryto cały żądany koniec okna. Przy nakładających się oknach +deduplikuj zapisane rekordy po `ksef_number`. + +Paczki eksportu mogą też zawierać `_metadata.json`, co pomaga uzgodnić pobraną +zawartość XML z wierszami metadanych przechowywanymi lokalnie. + +## Powiązane strony + + + + + + + + diff --git a/docs/pl/concepts/sessions.mdx b/docs/pl/concepts/sessions.mdx new file mode 100644 index 0000000..7296f6e --- /dev/null +++ b/docs/pl/concepts/sessions.mdx @@ -0,0 +1,168 @@ +--- +title: Sesje +description: Zrozum stan sesji online, batch, uwierzytelniania i historii sesji w ksef2. +--- + +import { Aside, LinkCard, Steps } from '@astrojs/starlight/components'; + +Sesja KSeF jest zdalnym kontenerem przepływu. Grupuje kilka wywołań API pod +jednym numerem referencyjnym, aby KSeF mógł przetworzyć zaszyfrowane faktury, +udostępnić status i wystawić dokumenty UPO po zakończeniu pracy. + +W SDK obiekt sesji jest uchwytem do tego zdalnego przepływu. Nie jest źródłem +prawdy. Źródłem prawdy jest numer referencyjny sesji i status zwracany przez +KSeF dla tej referencji. + +```text +klient uwierzytelniony + -> otwarcie sesji online albo batch + -> numer referencyjny sesji + -> wysyłka/upload zaszyfrowanych danych faktury + -> status, wyniki faktur, UPO +``` + +## Rodziny sesji + +KSeF ma kilka powierzchni przypominających sesje. Odpowiadają na różne pytania +i są dostępne przez różne gałęzie SDK. + +| Rodzina sesji | Gałąź SDK | Co reprezentuje | +| --- | --- | --- | +| Sesja faktur online | `auth.online_session()` | Krótki interaktywny przepływ dla wysyłki jednej albo kilku faktur. | +| Sesja faktur batch | `auth.batch_session()` albo `auth.batch` | Przepływ uploadu zaszyfrowanej paczki ZIP podzielonej na części. | +| Sesja uwierzytelniania | `auth.sessions` | Aktywne sesje bearer-token utworzone przez uwierzytelnianie. | +| Historia sesji faktur | `auth.invoice_sessions` | Historyczne sesje faktur online i batch, które można odpytać po zakończeniu pierwotnego procesu. | + +Pierwsze dwie rodziny są przepływami wysyłki faktur. Sesje uwierzytelniania i +historia sesji faktur służą do inspekcji albo administracji. + +## Sesje online + +Sesja online jest interaktywną ścieżką wysyłki. Otwiera się ją dla schematu +formularza, na przykład `FormSchema.FA3`, a potem wysyła faktury pojedynczo do +tej sesji. + +Otwarcie sesji jest lekką operacją. SDK ładuje publiczny certyfikat szyfrowania +KSeF, tworzy materiał szyfrowania sesji, otwiera zdalną sesję i zwraca +`OnlineSessionClient` przypisany do zwróconego `reference_number`. + +```python +from ksef2 import FormSchema + +with auth.online_session(form_code=FormSchema.FA3) as session: + state = session.resume_state() + print(state.reference_number, state.valid_until) +``` + +Klienta sesji online używaj do wywołań powiązanych z fakturami wysłanymi w tej +sesji: wysyłki, listy faktur sesji, sprawdzenia jednej faktury i pobrania UPO po +referencji faktury albo numerze KSeF. + +Context manager wywołuje `close()` przy wyjściu z bloku. Zamknięcie sesji online +informuje KSeF, że nie będzie już kolejnych faktur, i pozwala wygenerować +zbiorcze UPO dla sesji. + + + +## Sesje batch + +Sesja batch jest ścieżką wysyłki masowej. Jednostką wysyłaną do KSeF nie jest +pojedynczy plik XML. Jest nią przygotowana paczka ZIP zawierająca pliki XML +faktur, podzielona na części i zaszyfrowana przed uploadem. + +Normalny przepływ obsługuje wysokopoziomowy serwis `auth.batch`: + + + +1. Zbuduj paczkę ZIP z plików XML faktur albo z bajtów faktur w pamięci. + +2. Podziel paczkę na części przed szyfrowaniem. + +3. Zaszyfruj każdą część i policz metadane paczki oraz części. + +4. Otwórz sesję batch i odbierz instrukcje uploadu. + +5. Wgraj wszystkie części, zamknij sesję i polluj status. + + + +```python +prepared = auth.batch.prepare_batch_from_paths( + invoice_paths=["invoice-1.xml", "invoice-2.xml"], +) + +state = auth.batch.submit_prepared_batch(prepared_batch=prepared) +print(state.reference_number) +``` + +Dla przepływów batch zachowaj mapowanie między lokalnymi plikami źródłowymi a +metadanymi przygotowanych faktur. KSeF raportuje wyniki poszczególnych faktur +przez pola statusu faktury w sesji, takie jak `invoice_hash`, +`invoice_file_name`, `reference_number` i późniejszy `ksef_number`. + +## Stan, status i historia + +Te trzy pojęcia łatwo pomylić: + +| Pojęcie | Znaczenie | Kształt SDK | +| --- | --- | --- | +| Stan | Lokalne dane potrzebne do odtworzenia uchwytu sesji. Zawiera wrażliwy materiał szyfrowania sesji i, dla batcha, URL-e uploadu. | `OnlineSessionResumeState`, `BatchSessionResumeState` | +| Status | Aktualny obraz zdalnego przepływu po stronie KSeF. To on mówi, czy przetwarzanie się udało, nie udało albo nadal trwa. | `SessionStatusResponse`, `SessionInvoiceStatusResponse` | +| Historia | Lista poprzednich sesji online albo batch. Użyj jej, gdy nie masz już pierwotnego lokalnego obiektu. | `auth.invoice_sessions` | + +Stan sesji przydaje się do wznawiania operacji SDK, ale jest wrażliwy. Nie +wypisuj go i nie zapisuj w logach. Do audytu i wsparcia zapisuj odpowiedzi +statusowe oraz historię sesji. + +```python +status = auth.batch.get_status(session=state.reference_number) +print( + status.status.code, + status.invoice_count, + status.successful_invoice_count, + status.failed_invoice_count, +) +``` + +## Co zapisywać + +Zapisz co najmniej: + +- numer referencyjny sesji; +- numer referencyjny faktury zwrócony po wysyłce; +- numer KSeF po akceptacji; +- hash faktury albo nazwę pliku do korelacji batch; +- referencję UPO albo pobrane bajty UPO, gdy są dostępne; +- lokalny correlation id z własnego systemu. + +Sesja KSeF może żyć dłużej niż proces Pythona, który ją otworzył. Trwały zapis +tych identyfikatorów umożliwia ponowienia, późniejsze pobranie UPO i analizę +zgłoszeń. + +## Powiązane strony + + + + + diff --git a/docs/pl/concepts/status-and-upo.mdx b/docs/pl/concepts/status-and-upo.mdx new file mode 100644 index 0000000..07ab927 --- /dev/null +++ b/docs/pl/concepts/status-and-upo.mdx @@ -0,0 +1,169 @@ +--- +title: Status i UPO +description: Zrozum powierzchnie statusu, polling, dokumenty UPO i lokalne timeouty w przepływach ksef2. +--- + +import { Aside, LinkCard, Steps } from '@astrojs/starlight/components'; + +Przepływy KSeF są asynchroniczne. Wiele wywołań SDK rozpoczyna pracę i zwraca +numer referencyjny; inne wywołanie mówi, co stało się później. + +Nie sprowadzaj tego do jednej flagi `done`. Każda powierzchnia statusu odpowiada +na inne pytanie i ma inny numer referencyjny. + +## Którego statusu potrzebujesz? + +| Pytanie | Powierzchnia SDK | Typowy uchwyt | +| --- | --- | --- | +| Czy uwierzytelnianie się zakończyło? | `client.authentication` | referencja operacji uwierzytelniania | +| Czy sesja online albo batch nadal się przetwarza? | klient sesji albo `auth.batch` | numer referencyjny sesji | +| Czy jedna wysłana faktura się udała albo nie? | status faktury w sesji online/batch | numer referencyjny faktury | +| Czy przetworzone faktury są widoczne w wyszukiwaniu? | zapytania metadanych `auth.invoices` | filtry, paginacja, numer KSeF | +| Czy paczka eksportu jest gotowa? | status eksportu `auth.invoices` | numer referencyjny eksportu | +| Czy token jest aktywny albo cofnięty? | `auth.tokens` | numer operacji/referencji tokenu | +| Czy operacja uprawnień się zakończyła? | `auth.permissions` | referencja operacji uprawnień | + +Używaj powierzchni, która pasuje do pytania. Sesja może być zakończona, a jedna +faktura w środku może być błędna. Eksport może nadal się przygotowywać, mimo że +metadane faktur są już widoczne. Operacja tokenu kończy się niezależnie od +sesji wysyłki faktur. + +## Status sesji + +Status sesji jest podsumowaniem zdalnego przepływu online albo batch po stronie +KSeF. Po przetworzeniu może zawierać liczniki i referencje stron UPO. + +```python +status = session.get_status() +print( + status.status.code, + status.invoice_count, + status.successful_invoice_count, + status.failed_invoice_count, +) +``` + +Kod statusu dotyczy przepływu sesji, a niekoniecznie każdej faktury w środku. +Podsumowanie sesji może mówić, że przetwarzanie się zakończyło, i jednocześnie +raportować błędne faktury w `failed_invoice_count`. Gdy potrzebujesz szczegółów +per faktura, sprawdzaj faktury sesji. + +```python +failed = session.list_failed_invoices() +for invoice in failed.invoices: + print(invoice.reference_number, invoice.status.code, invoice.status.details) +``` + +## Status faktury + +Status faktury jest przypisany do jednej faktury w jednej sesji. Przy wysyłce +online numer referencyjny faktury zwykle dostajesz od razu z `send_invoice()`. +Przy wysyłce batch statusy poszczególnych faktur stają się widoczne po +przetworzeniu wgranej paczki przez KSeF. + +Zaakceptowany status faktury ma `ksef_number`. Błędny status faktury niesie kod +statusu KSeF, opis i opcjonalne szczegóły. + +```python +invoice_status = session.get_invoice_status( + invoice_reference_number=sent.reference_number, +) +print(invoice_status.ksef_number, invoice_status.status.description) +``` + +## Dokumenty UPO + +UPO jest urzędowym poświadczeniem. To XML podpisany przez Ministerstwo Finansów. +Traktuj zwrócone bajty jak artefakt audytowy i zapisuj je trwale. + +UPO nie jest dostępne w momencie wysyłki: + + + +1. Wyślij albo wgraj dane faktury. + +2. Poczekaj, aż KSeF przetworzy fakturę albo sesję. + +3. Odczytaj referencję UPO albo użyj referencji faktury/numeru KSeF. + +4. Pobierz i zapisz bajty XML UPO. + + + +SDK udostępnia typowe ścieżki UPO przez klientów sesji: + +```python +invoice_upo = session.get_invoice_upo_by_reference( + invoice_reference_number=sent.reference_number, +) + +batch_upo = auth.batch.get_upo( + session=batch_state.reference_number, + upo_reference_number="referencja-upo-ze-statusu-sesji", +) +``` + +Odpowiedzi statusowe mogą też wystawiać dane stron UPO, takie jak +`download_url_expiration_date`. Do wywołań SDK używaj referencji i zapisuj +pobrany XML, gdy już go masz. + +## Polling i timeouty + +Helpery oczekujące pollują KSeF do momentu, gdy stanie się jedna z trzech rzeczy: + +- pojawi się oczekiwany stan; +- KSeF zgłosi terminalny błąd klasyfikowany przez SDK; +- minie lokalny timeout. + +Lokalny timeout nie dowodzi, że zdalna operacja nie powiodła się. Oznacza tylko, +że stan docelowy nie został zaobserwowany przed twoim deadline. + + + +Dostosuj ustawienia pollingu do przepływu: + +| Przepływ | Typowy helper oczekujący | +| --- | --- | +| Akceptacja faktury online | `session.wait_for_invoice_ready()` albo `send_invoice_and_wait()` | +| Przetwarzanie batch | `auth.batch.wait_for_completion()` | +| Gotowość paczki eksportu | `auth.invoices.wait_for_export_package()` | +| Generowanie albo sprawdzanie tokenu | `auth.tokens.generate()` albo `auth.tokens.status()` | + +## Co zapisywać + +Zapisz informacje wystarczające do późniejszej inspekcji statusu bez pierwotnego +obiektu Pythona: + +- numer referencyjny sesji; +- numer referencyjny faktury; +- numer KSeF po akceptacji; +- referencję UPO albo bajty XML UPO; +- numer referencyjny eksportu albo operacji dla przepływów poza sesją; +- ostatni zaobserwowany payload statusu do audytu. + +## Powiązane strony + + + + + diff --git a/docs/pl/concepts/xades.mdx b/docs/pl/concepts/xades.mdx new file mode 100644 index 0000000..7706df0 --- /dev/null +++ b/docs/pl/concepts/xades.mdx @@ -0,0 +1,93 @@ +--- +title: XAdES +description: Zrozum krok podpisu XML używany przez uwierzytelnianie KSeF oparte o certyfikat. +--- + +import { Aside, LinkCard, Steps } from '@astrojs/starlight/components'; + +XAdES to format podpisu XML używany, gdy uwierzytelnianie KSeF opiera się o +certyfikat. W SDK XAdES należy do uwierzytelniania: podpisuje +`AuthTokenRequest` zbudowany z challenge KSeF i kontekstu logowania. + +Nie jest schematem XML faktury, szyfrowaniem sesji ani formatem paczki eksportu. + +## Co potwierdza XAdES + +Podczas uwierzytelniania XAdES KSeF sprawdza podpisany dokument XML i certyfikat +osadzony w podpisie. Kontekstem logowania jest NIP przekazany do metody SDK. +Podmiot uwierzytelniający jest odczytywany z certyfikatu podpisującego, na +przykład: + +- kwalifikowanego certyfikatu osoby fizycznej zawierającego PESEL albo NIP; +- kwalifikowanej pieczęci organizacji zawierającej NIP; +- certyfikatu KSeF do uwierzytelniania; +- certyfikatu rozpoznawanego przez uprawnienia na odcisk palca; +- materiału certyfikatu samopodpisanego tylko dla TEST. + +Następnie KSeF sprawdza, czy ten podmiot może działać w żądanym kontekście. + +## Warstwy SDK + +Większość aplikacji powinna używać `with_xades()` albo profilu, który wybiera tę +metodę: + +```python +auth = client.authentication.with_xades( + nip="5261040828", + cert=cert, + private_key=private_key, +) +``` + +Helper obsługuje zwykłą sekwencję: + + + +1. Pobierz challenge uwierzytelniania. +2. Zbuduj XML `AuthTokenRequest` dla kontekstu. +3. Podpisz XML przez XAdES. +4. Wyślij podpisany XML do KSeF. +5. Odpytuj operację uwierzytelniania. +6. Odbierz access i refresh tokeny. + + + +Używaj `ksef2.xades` bezpośrednio, gdy musisz samodzielnie załadować materiał +certyfikatu, obejrzeć podpisany XML albo przetestować granicę low-level +integracji. + + + +## Certyfikaty TEST + +SDK potrafi wygenerować materiał samopodpisanego certyfikatu tylko dla TEST +przez `with_test_certificate()` albo niskopoziomowe helpery w `ksef2.xades`. To +skrót developerski dla `Environment.TEST`; nie działa w DEMO ani PRODUKCJI. + +## Powiązane strony + + + + + diff --git a/docs/pl/contributing/sync-generation.md b/docs/pl/contributing/sync-generation.md index 468b31b..95620ff 100644 --- a/docs/pl/contributing/sync-generation.md +++ b/docs/pl/contributing/sync-generation.md @@ -98,5 +98,5 @@ Dla generacji sync oznacza to, że: ## Referencja -- [Konfiguracja klienta](../workflows/client-setup.mdx) -- [Low-level API](../raw/overview.md) +- [Konfiguracja klienta](../how-to-guides/client-setup.mdx) +- [Low-level API](../reference/low-level/overview.mdx) diff --git a/docs/pl/getting-started/overview.mdx b/docs/pl/getting-started/overview.mdx new file mode 100644 index 0000000..63cc772 --- /dev/null +++ b/docs/pl/getting-started/overview.mdx @@ -0,0 +1,107 @@ +--- +title: Przegląd +description: Zacznij tutaj, jeśli korzystasz z dokumentacji SDK ksef2 dla Pythona. +--- + +import logoDark from '../../../sdk/assets/logo-dark.png'; +import logoLight from '../../../sdk/assets/logo-light.png'; + +import { LinkCard } from '@astrojs/starlight/components'; + +
+ + +
+ + + +**ksef2** to w pełni typowane SDK dla języka Python do integracji z API systemu KSeF 2.0. + +> **Nieoficjalne SDK.** ksef2 jest projektem open-source utrzymywanym przez społeczność. \ +> Nie jest publikowany, zatwierdzany ani wspierany przez Ministerstwo Finansów. \ +> Oficjalna dokumentacja KSeF pozostaje źródłem informacji o działaniu systemu oraz zasadach pracy z nim. + + + +Projekt powstał z myślą o osobach, które budują własne integracje, +automatyzacje i narzędzia back-office wokół KSeF, ale nie chcą ręcznie +odtwarzać logiki HTTP, pętli odpytywania ani obsługi szyfrowania. + +Głównym założeniem SDK jest umożliwienie pracy z systemem KSeF przez wygodny +interfejs, który podąża za praktykami i wzorcami typowymi dla języka Python. + +Pozwala to skupić się na logice biznesowej zamiast na szczegółach API KSeF. +Biblioteka ukrywa wiele szczegółów komunikacji z systemem, ale nie zamyka drogi +do niższego poziomu. Jeśli potrzebujesz większej kontroli nad żądaniami i +odpowiedziami, możesz użyć niskopoziomowego dostępu do endpointów. + +## Następne kroki + + + + + + + + diff --git a/docs/pl/getting-started/quickstart.md b/docs/pl/getting-started/quickstart.md deleted file mode 100644 index c8c0b3b..0000000 --- a/docs/pl/getting-started/quickstart.md +++ /dev/null @@ -1,100 +0,0 @@ ---- -title: Quickstart -description: Wyślij i pobierz faktury klientem ksef2. ---- - -Ten przykład pokazuje podstawowy przepływ w środowisku TEST: klient, -uwierzytelnienie, wysyłka faktury FA(3) i eksport metadanych. - -## Instalacja - -```bash -pip install ksef2 -``` - -ksef2 wymaga Pythona 3.12 lub nowszego. - -## Wyślij fakturę - -```python -from datetime import datetime, timedelta, timezone -from pathlib import Path - -from ksef2 import Client, Environment, FormSchema -from ksef2.domain.models import InvoicesFilter - -NIP = "5261040828" - -client = Client(Environment.TEST) -auth = client.authentication.with_test_certificate(nip=NIP) - -with auth.online_session(form_code=FormSchema.FA3) as session: - status = session.send_invoice_and_wait( - invoice_xml=Path("invoice.xml").read_bytes(), - timeout=60.0, - ) - print(status.ksef_number) - -filters = InvoicesFilter( - role="seller", - date_type="issue_date", - date_from=datetime.now(tz=timezone.utc) - timedelta(days=1), - date_to=datetime.now(tz=timezone.utc), - amount_type="brutto", -) - -export = auth.invoices.schedule_export(filters=filters) -package = auth.invoices.wait_for_export_package( - reference_number=export.reference_number, - timeout=120.0, -) - -for path in auth.invoices.fetch_package( - package=package, - export=export, - target_directory="downloads", -): - print(path) -``` - -Przy powtarzalnej pracy lokalnej lub produkcyjnej utwórz profil kompatybilny z -CLI i użyj tego samego profilu z Pythona: - -```python -from ksef2 import Client, Environment - -client = Client(Environment.PRODUCTION) -auth = client.authentication.with_profile("prod-token") -``` - -## Wersja async - -```python -import asyncio -from pathlib import Path - -from ksef2 import AsyncClient, Environment, FormSchema - - -async def main() -> None: - async with AsyncClient(Environment.TEST) as client: - auth = await client.authentication.with_test_certificate(nip="5261040828") - - async with auth.online_session(form_code=FormSchema.FA3) as session: - status = await session.send_invoice_and_wait( - invoice_xml=Path("invoice.xml").read_bytes(), - timeout=60.0, - ) - print(status.ksef_number) - - -asyncio.run(main()) -``` - -## Co dalej - -- [Konfiguracja klienta](../workflows/client-setup.mdx) -- [Uwierzytelnianie](../workflows/authentication.mdx) -- [Wysyłanie faktur](../workflows/sending-invoices.mdx) -- [Pobieranie faktur](../workflows/downloading-invoices.mdx) -- [Referencja API](../reference/api-signatures.md) diff --git a/docs/pl/getting-started/quickstart.mdx b/docs/pl/getting-started/quickstart.mdx new file mode 100644 index 0000000..5684d19 --- /dev/null +++ b/docs/pl/getting-started/quickstart.mdx @@ -0,0 +1,217 @@ +--- +title: Szybki start +description: Wygeneruj fakturę TEST, wyślij ją do KSeF, pobierz UPO i sprawdź zwrócony XML. +--- + +import { Aside, LinkCard, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; + +Ten przykład przeprowadza jedną fakturę TEST przez pełny scenariusz w SDK: +zbudowanie faktury FA(3), uwierzytelnienie certyfikatem TEST, wysłanie faktury +w sesji online, pobranie UPO oraz przetworzonego XML faktury z KSeF. + +## Instalacja + +Wybierz preferowaną metodę instalacji: + + + +```bash +pip install ksef2 +``` + + + + +```bash +uv add ksef2 +``` + + + + +ksef2 wymaga Pythona 3.12 lub nowszego. + +## Przykład kodu + +Skopiuj poniższy kod do pliku `quickstart.py`. + +```python title="quickstart.py" +from datetime import date, datetime, timezone +from decimal import Decimal +from pathlib import Path + +from ksef2 import Client, Environment, FormSchema +from ksef2.fa3 import FA3InvoiceBuilder, VatRate + + +SELLER_NIP = "5261040828" +DOWNLOADS = Path("downloads") + +invoice_number = f"QS/{datetime.now(timezone.utc):%Y%m%d%H%M%S}" +invoice_xml = ( + FA3InvoiceBuilder() + .header(system_info="ksef2 quickstart") + .seller( + name="Demo Seller Sp. z o.o.", + tax_id=SELLER_NIP, + country_code="PL", + address_line_1="Prosta 1", + address_line_2="00-001 Warszawa", + ) + .buyer( + name="Demo Buyer Sp. z o.o.", + country_code="PL", + address_line_1="Kwiatowa 2", + address_line_2="00-002 Warszawa", + ) + .standard() + .issue_date(date.today()) + .issue_place("Warszawa") + .invoice_number(invoice_number) + .rows() + .add_line( + name="Consulting service", + quantity=Decimal("1"), + unit_price_net=Decimal("100.00"), + vat_rate=VatRate.VAT_23, + ) + .done() + .done() + .to_xml() + .encode("utf-8") +) + +DOWNLOADS.mkdir(exist_ok=True) +(DOWNLOADS / "generated-invoice.xml").write_bytes(invoice_xml) + +with Client(Environment.TEST) as client: + auth = client.authentication.with_test_certificate(nip=SELLER_NIP) + + with auth.online_session(form_code=FormSchema.FA3) as session: + sent = session.send_invoice(invoice_xml=invoice_xml) + print("Sent invoice:") + print(sent.model_dump_json(indent=2)) + + status = session.wait_for_invoice_ready( + invoice_reference_number=sent.reference_number, + timeout=120.0, + ) + print("Invoice status:") + print(status.model_dump_json(indent=2)) + + upo_xml = session.get_invoice_upo_by_reference( + invoice_reference_number=sent.reference_number, + ) + (DOWNLOADS / "upo.xml").write_bytes(upo_xml) + print("Saved downloads/upo.xml") + + if status.ksef_number is None: + raise RuntimeError("KSeF did not assign an invoice number.") + + downloaded_xml = auth.invoices.wait_for_invoice_download( + ksef_number=status.ksef_number, + timeout=120.0, + ) + (DOWNLOADS / "processed-invoice.xml").write_bytes(downloaded_xml) + print("Saved downloads/processed-invoice.xml") +``` + +Uruchom skrypt: + +```bash +python quickstart.py +# albo +uv run quickstart.py +``` + +Skrypt zapisze trzy lokalne pliki: + +- `downloads/generated-invoice.xml` +- `downloads/upo.xml` +- `downloads/processed-invoice.xml` + +Po zakończeniu sprawdź wygenerowany XML, UPO oraz XML zwrócony przez KSeF. +Wygenerowany i przetworzony XML powinny opisywać tę samą fakturę; zachowaj +przetworzony XML jako kopię zwróconą przez KSeF. + + + +## Co robi ten przykład + + + +1. `FA3InvoiceBuilder` utworzył minimalny XML FA(3) z nazwami pól SDK, takimi + jak `invoice_number`, `tax_id` i `unit_price_net`. + +2. `Client(Environment.TEST)` wybrał adresy bazowe środowiska TEST KSeF i + zarządzał zasobami HTTP używanymi przez SDK. + +3. `with_test_certificate()` uwierzytelniło kontekst TEST wskazany przez + `SELLER_NIP`. + +4. `online_session(FormSchema.FA3)` otworzyło sesję online do wysłania faktury + FA(3). + +5. `send_invoice()` wysłało fakturę i zwróciło numer referencyjny faktury w + sesji. + +6. `wait_for_invoice_ready()` cyklicznie sprawdzało status, aż KSeF nadał + numer faktury. + +7. `get_invoice_upo_by_reference()` pobrało XML UPO, a + `auth.invoices.wait_for_invoice_download()` pobrało przetworzony XML + faktury. + + + +## Dostosuj skrypt + +Zmień pola faktury albo dodaj kolejne pozycje, gdy dopasowujesz skrypt. Aby +przenieść ten sam kształt do DEMO albo PRODUCTION, zmień główne `Environment` i +użyj credentiala przypisanego do tego środowiska. + +Do codziennej pracy lokalnej utwórz profil kompatybilny z CLI i zastąp linię +uwierzytelnienia wywołaniem `client.authentication.with_profile("test-company")`. +Profil musi wskazywać to samo środowisko co klient główny. + +## Powiązane strony + + + + + + + + diff --git a/docs/pl/guides/admin.md b/docs/pl/guides/admin.md deleted file mode 100644 index ccafeec..0000000 --- a/docs/pl/guides/admin.md +++ /dev/null @@ -1,69 +0,0 @@ ---- -title: Przepływy administracyjne -description: Tokeny, uprawnienia, certyfikaty, limity, dane TEST i pomocnicze wyszukiwarki. ---- - -Większość administracyjnych przepływów KSeF jest dostępna po uwierzytelnieniu -przez gałęzie klienta `auth`. - -## Tokeny - -```python -token = auth.tokens.generate( - permissions=["invoice_read"], - description="nightly export", -) -print(token.token) -``` - -## Uprawnienia - -```python -operation = auth.permissions.grant_person( - subject_type="pesel", - subject_value="90010112345", - permissions=["invoice_read"], - description="Read invoices", - first_name="Jan", - last_name="Kowalski", -) - -status = auth.permissions.get_operation_status( - reference_number=operation.reference_number, -) -print(status.status) -``` - -## Certyfikaty, limity i TEST - -```python -limits = auth.certificates.get_limits() -print(limits.can_request) - -context_limits = auth.limits.get_context_limits() -print(context_limits.online_session.max_invoices) - -client.testdata.create_subject( - nip="5261040828", - subject_type="vat_group", - description="Quickstart company", -) -``` - -Gałąź `testdata` działa tylko w `Environment.TEST`. - -## Publiczne wyszukiwarki - -```python -certificates = client.encryption.get_certificates() -providers = client.peppol.query() -``` - -## Referencja - -- [Tokens API](../reference/api/tokens.md) -- [Permissions API](../reference/api/permission-grants.md) -- [Certificates API](../reference/api/certificates.md) -- [Limits API](../reference/api/limits.md) -- [TEST data API](../reference/api/testdata.md) -- [PEPPOL API](../reference/api/peppol.md) diff --git a/docs/pl/guides/client.md b/docs/pl/guides/client.md deleted file mode 100644 index 069e16a..0000000 --- a/docs/pl/guides/client.md +++ /dev/null @@ -1,98 +0,0 @@ ---- -title: Klient -description: Utwórz klienta ksef2, uwierzytelnij się i wybierz sync albo async. ---- - -Klient główny wybiera środowisko i udostępnia publiczne wejścia SDK. Najpierw -tworzysz klienta, potem uwierzytelniasz się, a następnie korzystasz z gałęzi -`auth`. - -## Utwórz klienta - -```python -from ksef2 import Client, Environment - -client = Client(Environment.TEST) -``` - -Poza TEST użyj `Environment.DEMO` albo `Environment.PRODUCTION`. - -```python -from ksef2 import AsyncClient, Environment - -async with AsyncClient(Environment.TEST) as client: - ... -``` - -## Uwierzytelnij się - -W TEST możesz użyć certyfikatu generowanego przez SDK. - -```python -auth = client.authentication.with_test_certificate(nip="5261040828") -``` - -DEMO i PRODUCTION wymagają prawdziwego certyfikatu albo istniejącego tokenu -KSeF. - -```python -from ksef2.xades import load_certificate_from_pem, load_private_key_from_pem - -cert = load_certificate_from_pem("company.pem") -key = load_private_key_from_pem("company.key") - -auth = Client(Environment.DEMO).authentication.with_xades( - nip="5261040828", - cert=cert, - private_key=key, -) -``` - -```python -auth = client.authentication.with_token( - ksef_token="your-ksef-token", - nip="5261040828", -) -``` - -## Użyj gałęzi po uwierzytelnieniu - -```python -from ksef2 import FormSchema - -with auth.online_session(form_code=FormSchema.FA3) as session: - ... - -invoices = auth.invoices -tokens = auth.tokens -permissions = auth.permissions -certificates = auth.certificates -``` - -## Błędy - -Łap błędy sklasyfikowane przez SDK przez `KSeFException`. Błędy transportu HTTP -łap osobno. - -```python -import httpx - -from ksef2 import KSeFException - -try: - auth = client.authentication.with_test_certificate(nip="5261040828") -except KSeFException as exc: - print(exc) -except httpx.HTTPError as exc: - print(f"Transport failed: {exc}") -``` - -## Referencja - -- [Przepływ uwierzytelniania](../workflows/authentication.mdx) -- [Kontrakt publicznego API](public-api.md) -- [Obsługa błędów](errors.md) -- [Low-level API](../raw/overview.md) -- [Access API](../reference/api/access.md) -- [Active sessions API](../reference/api/active-sessions.md) -- [Errors reference](../reference/api/errors.md) diff --git a/docs/pl/guides/errors.md b/docs/pl/guides/errors.md deleted file mode 100644 index 8709f02..0000000 --- a/docs/pl/guides/errors.md +++ /dev/null @@ -1,143 +0,0 @@ ---- -title: Obsługa błędów -description: Łap wyjątki SDK, sprawdzaj odpowiedzi KSeF i obsługuj timeouty pollingu. ---- - -Używaj wyjątków SDK dla błędów zwróconych przez KSeF oraz błędów, które SDK -potrafi sklasyfikować. `httpx.HTTPError` łap osobno dla problemów transportu, -gdy SDK nie sparsowało odpowiedzi KSeF. - -## Łap błędy SDK i transportu osobno - -```python -import httpx - -from ksef2 import KSeFApiError, KSeFException - -try: - result = auth.invoices.query_metadata(filters=filters) -except KSeFApiError as exc: - print(exc.status_code) - print(exc.exception_code) -except KSeFException as exc: - print(exc.context) -except httpx.HTTPError as exc: - print(f"Network or transport failure: {exc}") -``` - -Łap konkretne wyjątki SDK przed `KSeFException`. `KSeFException` jest klasą -bazową dla błędów sklasyfikowanych przez SDK: odpowiedzi API, walidacji, -szyfrowania, cyklu życia sesji i timeoutów pollingu. - -## Sprawdzaj szczegóły odpowiedzi API - -`KSeFApiError` jest rzucany dla odpowiedzi KSeF 4xx i 5xx. Udostępnia: - -- `status_code`: kod HTTP zwrócony przez KSeF; -- `exception_code`: znormalizowany `ExceptionCode`, jeśli KSeF zwrócił znany - kod wyjątku; -- `response`: sparsowany model błędu KSeF, jeśli body dało się sparsować. - -```python -from ksef2 import ExceptionCode, KSeFApiError - -try: - xml = auth.invoices.download_invoice(ksef_number=ksef_number) -except KSeFApiError as exc: - if exc.exception_code is ExceptionCode.NOT_PROCESSED_YET: - print("KSeF zna fakturę, ale nie jest jeszcze gotowa.") - if exc.response is not None: - print(exc.response.model_dump_json(indent=2)) -``` - -Sparsowany model `response` zachowuje kształt payloadu KSeF. Używaj -`model_dump()` albo `model_dump_json()` do logowania strukturalnych diagnostyk. - -## Obsługuj limity - -Odpowiedzi KSeF `429` rzucają `KSeFRateLimitError`. Jeśli KSeF wyśle nagłówek -`Retry-After`, SDK udostępni go jako `retry_after`. - -```python -from time import sleep - -from ksef2 import KSeFRateLimitError - -try: - page = auth.invoices.query_metadata(filters=filters) -except KSeFRateLimitError as exc: - delay = exc.retry_after if exc.retry_after is not None else 5 - sleep(delay) -``` - -W workerach tła połącz `retry_after` z polityką kolejki albo retry zamiast -usypiać request handler. - -## Obsługuj timeouty pollingu - -Operacje, które odpytują KSeF, rzucają wyjątki timeout SDK po przekroczeniu -parametru `timeout`. To nie są timeouty HTTP. Oznaczają, że SDK odpytywało KSeF, -ale KSeF nie osiągnął oczekiwanego stanu w czasie. - -| Operacja | Wyjątek timeout | -| --- | --- | -| Polling uwierzytelniania | `KSeFAuthPollingTimeoutError` | -| Polling aktywacji tokenu | `KSeFTokenStatusTimeoutError` | -| Przetwarzanie faktury online | `KSeFInvoiceProcessingTimeoutError` | -| Widoczność metadanych faktur | `KSeFInvoiceQueryTimeoutError` | -| Gotowość pobrania faktury | `KSeFInvoiceDownloadTimeoutError` | -| Gotowość paczki eksportu | `KSeFExportTimeoutError` | -| Zakończenie sesji batch | `KSeFBatchSessionTimeoutError` | - -Większość wyjątków timeout zawiera właściwy numer referencyjny oraz `timeout`. - -```python -from ksef2 import KSeFInvoiceProcessingTimeoutError - -try: - status = session.wait_for_invoice_ready( - invoice_reference_number=reference_number, - timeout=60.0, - ) -except KSeFInvoiceProcessingTimeoutError as exc: - print(exc.invoice_reference_number) - print(exc.timeout) -``` - -Zapisuj referencje sesji i faktur przed pollingiem. Kolejny worker może wznowić -sprawdzanie statusu, nawet jeśli pierwszy proces przekroczy timeout. - -## Ponawiaj `NOT_PROCESSED_YET` - -Część niższopoziomowych wywołań KSeF może zwrócić -`ExceptionCode.NOT_PROCESSED_YET`, gdy zasób istnieje, ale nie jest jeszcze -gotowy. Wysokopoziomowe helpery wait obsługują to tam, gdzie jest to część -workflow, na przykład `wait_for_invoice_download()`. - -```python -xml = auth.invoices.wait_for_invoice_download( - ksef_number=ksef_number, - timeout=120.0, - poll_interval=2.0, -) -``` - -Jeśli wywołujesz niższe poziomy bezpośrednio, traktuj `NOT_PROCESSED_YET` jako -stan ponawialny tylko dla operacji, w których KSeF dokumentuje asynchroniczną -dostępność. Nie ponawiaj błędów walidacji albo autoryzacji jak opóźnień -przetwarzania. - -## Zalecany przepływ - -1. Łap najwęższy wyjątek SDK, na który workflow potrafi zareagować. -2. Używaj `KSeFApiError.response` do strukturalnych diagnostyk. -3. Używaj `KSeFRateLimitError.retry_after` do planowania ponowień. -4. Traktuj timeouty pollingu SDK jako wznawialny stan workflow, nie utracone - zadanie. -5. Łap `httpx.HTTPError` osobno dla błędów sieci, TLS, DNS i połączenia. - -## Referencja - -- [Klient](client.md) -- [Status i UPO](../workflows/status-upo.mdx) -- [Errors reference](../reference/api/errors.md) diff --git a/docs/pl/guides/fa3-builder.md b/docs/pl/guides/fa3-builder.md new file mode 100644 index 0000000..3de99f5 --- /dev/null +++ b/docs/pl/guides/fa3-builder.md @@ -0,0 +1,66 @@ +--- +title: Builder FA(3) +description: Buduj XML faktury FA(3) typowanymi pomocnikami SDK. +--- + +Użyj `ksef2.fa3`, gdy chcesz budować XML FA(3) w Pythonie zamiast pisać XML +ręcznie. + +```python +from datetime import date +from decimal import Decimal + +from ksef2.fa3 import FA3InvoiceBuilder, VatRate + +builder = ( + FA3InvoiceBuilder() + .header(system_info="my app") + .seller( + name="ACME S.A.", + tax_id="1234567890", + country_code="PL", + address_line_1="ul. Przykladowa 123", + ) + .buyer(name="XYZ GmbH", country_code="DE", address_line_1="Unter den Linden 1") + .standard() + .issue_date(date(2026, 3, 29)) + .invoice_number("FV/2026/03/0001") + .rows() + .add_line( + name="Consulting service", + quantity=Decimal("10"), + unit_of_measure="h", + unit_price_net=Decimal("100.00"), + vat_rate=VatRate.VAT_23, + ) + .done() + .done() +) + +xml_text = builder.to_xml() +``` + +## Przepływ + +1. Utwórz `FA3InvoiceBuilder`. +2. Uzupełnij `header(...)`, `seller(...)` i `buyer(...)`. +3. Wybierz typ faktury, np. `standard()` albo `correction()`. +4. Dodaj sekcje, np. `rows()`, `payment()` albo `annotations()`. +5. Zakończ przez `build()`, `to_spec()` albo `to_xml()`. + +## Wyślij XML + +```python +from ksef2 import FormSchema + +with auth.online_session(form_code=FormSchema.FA3) as session: + status = session.send_invoice_and_wait( + invoice_xml=builder.to_xml().encode("utf-8"), + ) + print(status.ksef_number) +``` + +## Referencja + +- [FA(3) API reference](../reference/api/fa3.md) +- [Faktury](invoices.md) diff --git a/docs/pl/guides/invoices.md b/docs/pl/guides/invoices.md deleted file mode 100644 index f1f2267..0000000 --- a/docs/pl/guides/invoices.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -title: Faktury -description: Wysyłaj, wyszukuj, eksportuj i pobieraj faktury przez ksef2. ---- - -Użyj sesji online do wysyłki XML. Użyj `auth.invoices` do metadanych, eksportów, -paczek i pobierania faktur po numerze KSeF. - -## Wyślij fakturę - -```python -from pathlib import Path - -from ksef2 import FormSchema - -with auth.online_session(form_code=FormSchema.FA3) as session: - status = session.send_invoice_and_wait( - invoice_xml=Path("invoice.xml").read_bytes(), - timeout=60.0, - ) - print(status.ksef_number) -``` - -## Metadane - -```python -from datetime import datetime, timedelta, timezone - -from ksef2.domain.models import InvoicesFilter - -filters = InvoicesFilter( - role="seller", - date_type="issue_date", - date_from=datetime.now(tz=timezone.utc) - timedelta(days=7), - date_to=datetime.now(tz=timezone.utc), - amount_type="brutto", -) - -for invoice in auth.invoices.all_metadata(filters=filters): - print(invoice.ksef_number) -``` - -## Eksport i pobieranie - -```python -zip_parts = auth.invoices.export_and_download(filters=filters) -print(len(zip_parts)) -``` - -```python -xml_bytes = auth.invoices.download_invoice(ksef_number="KSeF-number") -print(len(xml_bytes)) -``` - -## Referencja - -- [Wysyłanie faktur](../workflows/sending-invoices.mdx) -- [Wyszukiwanie faktur](../workflows/querying-invoices.mdx) -- [Pobieranie faktur](../workflows/downloading-invoices.mdx) -- [Interactive sending API](../reference/api/interactive-sending.md) -- [Invoice retrieval API](../reference/api/invoice-retrieval.md) -- [Status and UPO API](../reference/api/status-upo.md) diff --git a/docs/pl/workflows/authentication.mdx b/docs/pl/how-to-guides/authenticate.mdx similarity index 79% rename from docs/pl/workflows/authentication.mdx rename to docs/pl/how-to-guides/authenticate.mdx index fdfa49d..0fd6af5 100644 --- a/docs/pl/workflows/authentication.mdx +++ b/docs/pl/how-to-guides/authenticate.mdx @@ -1,9 +1,9 @@ --- -title: Uwierzytelnianie +title: Uwierzytelnij się description: Skonfiguruj dane uwierzytelniające ksef2 przez zmienne środowiskowe, tokeny KSeF, certyfikaty XAdES albo profile kompatybilne z CLI. --- -import { Aside, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; +import { Aside, LinkCard, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; Uwierzytelnianie zamienia głównego `Client` w klienta uwierzytelnionego. Klient główny wybiera środowisko KSeF, a metoda uwierzytelnienia wybiera kontekst, w @@ -42,12 +42,16 @@ tokenem albo certyfikatem trzymaj granicę konfiguracji jawną w aplikacji: ```python import os -from ksef2 import Client, Environment +from ksef2 import Environment environment_name = os.environ.get("KSEF2_ENV", "test").upper() -client = Client(Environment[environment_name]) +environment = Environment[environment_name] ``` +Poniższe przykłady metod zakładają, że `client` jest otwartym klientem głównym +dla wybranego środowiska. W skryptach preferuj context manager, na przykład +`with Client(Environment.TEST) as client:`. + ## Wybierz metodę @@ -179,11 +183,11 @@ Użyj istniejącego profilu w kodzie SDK: ```python from ksef2 import Client, Environment -client = Client(Environment.PRODUCTION) -auth = client.authentication.with_profile() +with Client(Environment.PRODUCTION) as client: + auth = client.authentication.with_profile() -# Albo jawnie wybrany profil. -auth = client.authentication.with_profile("prod-token") + # Aby zamiast tego jawnie wybrać profil: + # auth = client.authentication.with_profile("prod-token") ``` Środowisko klienta głównego musi zgadzać się ze środowiskiem wybranego profilu. @@ -191,7 +195,7 @@ auth = client.authentication.with_profile("prod-token") Utwórz albo zaktualizuj ten sam plik profili z kodu SDK: ```python -from ksef2 import Client, Environment +from ksef2 import Environment from ksef2.profiles import Profile, ProfileStore, TokenProfileAuth store = ProfileStore.default() @@ -217,6 +221,17 @@ async with AsyncClient(Environment.PRODUCTION) as client: auth = await client.authentication.with_profile("prod-token") ``` + + + ## Zalecany przepływ @@ -250,10 +265,11 @@ Błędy sklasyfikowane przez SDK łap przez `KSeFException`. Błędy transportu ```python import httpx -from ksef2 import KSeFException +from ksef2 import Client, Environment, KSeFException try: - auth = client.authentication.with_test_certificate(nip="5261040828") + with Client(Environment.TEST) as client: + auth = client.authentication.with_test_certificate(nip="5261040828") except KSeFException as exc: print(exc) except httpx.HTTPError as exc: @@ -262,6 +278,18 @@ except httpx.HTTPError as exc: ## Następne przepływy -- [Wysyłanie faktur](sending-invoices.mdx) -- [Wyszukiwanie faktur](querying-invoices.mdx) -- [Pobieranie faktur](downloading-invoices.mdx) + + + diff --git a/docs/pl/how-to-guides/build-fa3-invoices.mdx b/docs/pl/how-to-guides/build-fa3-invoices.mdx new file mode 100644 index 0000000..9e08975 --- /dev/null +++ b/docs/pl/how-to-guides/build-fa3-invoices.mdx @@ -0,0 +1,482 @@ +--- +title: Zbuduj faktury FA(3) +description: Wygeneruj pełne modele faktur FA(3) i XML przez fluent builder. +--- + +import { Aside, LinkCard, Steps } from '@astrojs/starlight/components'; + +Użyj `FA3InvoiceBuilder`, gdy aplikacja ma dane faktury jako obiekty Pythona i +chcesz, żeby SDK zbudowało typowany model FA(3) oraz XML. Builder używa +publicznych nazw pól SDK, a potem tworzy ten sam agregat `KsefInvoice`, którego +używają mappery FA(3). + + + +## Kształt buildera + +Builder ma trzy poziomy: + +| Poziom | Co zawiera | Typowe metody | +| --- | --- | --- | +| Root faktury | Nagłówek, sprzedawcę, nabywcę, podmioty trzecie, stopkę, załącznik i finalny wynik. | `header()`, `seller()`, `buyer()`, `third_party()`, `footer()`, `attachment()`, `build()`, `to_xml()` | +| Ścieżka body | Rodzaj faktury FA(3) i pola wspólne dla body. | `standard()`, `simplified()`, `correction()`, `advance()`, `settlement()`, `correction_advance()`, `correction_settlement()` | +| Sekcje zagnieżdżone | Powtarzalne albo opcjonalne szczegóły body. | `rows()`, `payment()`, `annotations()`, `correction()`, `order()`, `advance()`, `settlement()`, `transaction()` | + +Każdy zagnieżdżony builder wraca do rodzica przez `.done()`. Root builder może +zwrócić publiczny model przez `build()`, wygenerowany model schemy FA(3) przez +`to_spec()` albo tekst XML przez `to_xml()`. + +## Zbuduj fakturę standardową + +Najpierw ustaw strony faktury na root builderze, wybierz ścieżkę `.standard()`, +a potem uzupełnij sekcje zagnieżdżone. + +```python +from datetime import date +from decimal import Decimal + +from ksef2.fa3 import FA3InvoiceBuilder, VatRate + +builder = ( + FA3InvoiceBuilder() + .header(system_info="ksef2 builder guide") + .seller( + name="Demo Seller Sp. z o.o.", + tax_id="5261040828", + country_code="PL", + address_line_1="Prosta 1", + address_line_2="00-001 Warszawa", + ) + .buyer( + name="Demo Buyer Sp. z o.o.", + tax_id="5250001009", + country_code="PL", + address_line_1="Kwiatowa 2", + address_line_2="00-002 Warszawa", + ) + .standard() + .issue_date(date(2026, 6, 24)) + .issue_place("Warszawa") + .invoice_number("FV/1/2026") + .rows() + .add_line( + name="Usługa wdrożeniowa", + quantity=Decimal("2"), + unit_price_net=Decimal("150.00"), + vat_rate=VatRate.VAT_23, + ) + .add_line( + name="Pakiet wsparcia", + quantity=Decimal("1"), + unit_price_net=Decimal("50.00"), + vat_rate=VatRate.VAT_8, + ) + .done() + .payment() + .via("bank_transfer") + .due_on(date(2026, 7, 8)) + .bank_account("PL10101010101010101010101010") + .done() + .annotations() + .split_payment() + .done() + .done() +) +``` + +Wywołaj `build()`, gdy potrzebujesz typowanego publicznego modelu faktury. + +```python +invoice = builder.build() + +print(invoice.body.invoice_number) +# FV/1/2026 +print(invoice.total_net) +# 350.00 +print(invoice.total_vat) +# 73.00 +print(invoice.total_gross) +# 423.00 +``` + +## Wybierz ścieżkę faktury + +Po `header()`, `seller()` i `buyer()` wybierz jeden selektor body: + +| Selektor | Zastosowanie | Typowe sekcje zagnieżdżone | +| --- | --- | --- | +| `standard()` | Zwykłe faktury VAT. | `rows()`, `payment()`, `annotations()`, `transaction()`, `settlement()` | +| `simplified()` | Faktury uproszczone. | `rows()`, `payment()`, `annotations()`, `settlement()` | +| `correction()` | Korekty wcześniejszych faktur. | `rows()`, `correction()`, `payment()`, `annotations()` | +| `advance()` | Faktury zaliczkowe. | `order()`, `payment()`, `advance()`, `annotations()`, `transaction()` | +| `settlement()` | Faktury rozliczające zaliczki. | `rows()`, `payment()`, `advance()`, `settlement()`, `transaction()` | +| `correction_advance()` | Korekty faktur zaliczkowych. | `order()`, `correction()`, `advance()`, `payment()` | +| `correction_settlement()` | Korekty faktur rozliczających. | `rows()`, `correction()`, `advance()`, `settlement()`, `payment()` | + +Wszystkie selektory body mają wspólne metody, między innymi `currency()`, +`issue_date()`, `issue_place()`, `invoice_number()`, `date_of_supply()`, +`billing_period()`, `add_description()`, `mark_fp()`, +`related_party_transaction()` i `summary_overrides()`. + +## Dodaj pozycje faktury + +Użyj `rows().add_line()` dla zwykłych danych aplikacji. Builder wylicza brakujące +sumy pozycji z `quantity`, jednej ceny jednostkowej i klasyfikacji VAT. Przekaż +`unit_price_net` albo `unit_price_gross`, ale nie oba naraz. + +```python +builder = ( + FA3InvoiceBuilder() + .header(system_info="rows example") + .seller( + name="Demo Seller Sp. z o.o.", + tax_id="5261040828", + country_code="PL", + address_line_1="Prosta 1", + ) + .buyer( + name="Demo Buyer Sp. z o.o.", + tax_id="5250001009", + country_code="PL", + address_line_1="Kwiatowa 2", + ) + .standard() + .issue_date(date(2026, 6, 24)) + .invoice_number("FV/2/2026") + .rows() + .add_line( + name="Usługa w cenie brutto", + quantity=Decimal("2"), + unit_price_gross=Decimal("123.00"), + vat_rate=VatRate.VAT_23, + ) + .done() + .done() +) +``` + +Użyj `add_line_model()` albo `replace_lines()`, gdy inna warstwa utworzyła już +modele `InvoiceRow`. Użyj `summary_overrides()`, gdy oficjalny przypadek FA(3) +wymaga wartości podsumowania, których nie należy wyliczać z pozycji. + +## Dodaj płatności i adnotacje + +Builder płatności obsługuje formę płatności, terminy, płatności częściowe, +rachunki bankowe, rachunki faktora, rabaty, linki płatności i IP KSeF. + +```python +_ = ( + builder.standard() + .payment() + .via("bank_transfer") + .due_on(date(2026, 7, 8)) + .add_partial_payment( + amount=Decimal("50.00"), + payment_date=date(2026, 6, 24), + payment_form="card", + ) + .bank_account( + "PL10101010101010101010101010", + bank_name="Demo Bank S.A.", + account_description="Rachunek PLN", + ) + .done() + .annotations() + .split_payment() + .cash_accounting(False) + .margin_procedure("travel_agency") + .done() + .done() +) +``` + +Większość metod adnotacji przyjmuje wartości boolowskie albo stringi podobne do +enumów i zapisuje odpowiednią flagę FA(3) tylko wtedy, gdy jest potrzebna. + +## Zbuduj korektę + +Korekty zostają w fluent builderze. Użyj selektora `.correction()`, dodaj +ujemne albo przed/po pozycje, a potem otwórz zagnieżdżoną sekcję `.correction()` +z powodem korekty i referencjami faktur korygowanych. + +```python +from datetime import date +from decimal import Decimal + +from ksef2.fa3 import FA3InvoiceBuilder, InvoiceSummaryOverrides, VatRate + +correction = ( + FA3InvoiceBuilder() + .header(system_info="ksef2 correction guide") + .seller( + name="Demo Seller Sp. z o.o.", + tax_id="5261040828", + country_code="PL", + address_line_1="Prosta 1", + ) + .buyer( + name="Demo Buyer Sp. z o.o.", + tax_id="5250001009", + country_code="PL", + address_line_1="Kwiatowa 2", + ) + .correction() + .issue_date(date(2026, 7, 1)) + .issue_place("Warszawa") + .invoice_number("FK/1/2026") + .summary_overrides( + InvoiceSummaryOverrides( + base_rate_net_total=Decimal("-100.00"), + base_rate_vat_total=Decimal("-23.00"), + total_gross=Decimal("-123.00"), + ) + ) + .rows() + .add_line( + name="Korekta usługi wdrożeniowej", + quantity=Decimal("1"), + unit_price_net=Decimal("-100.00"), + vat_rate=VatRate.VAT_23, + ) + .done() + .correction() + .reason("Korekta ceny") + .effect_type("other_date") + .add_corrected_invoice( + issue_date=date(2026, 6, 24), + invoice_number="FV/1/2026", + ksef_id="5261040828-20260624-ABCDEF-ABCDEF-FF", + ) + .done() + .done() + .build() +) +``` + +Sekcja korekty obsługuje też okres faktury korygowanej, skorygowane dane +sprzedawcy, skorygowanych nabywców oraz metody przyjmujące gotowe modele FA(3). + +## Zbuduj zaliczkę i rozliczenie + +Faktura zaliczkowa łączy ścieżkę body z zamówieniem, płatnością i opcjonalnymi +referencjami zaliczek. Faktura rozliczeniowa może potem wskazać wcześniejsze +faktury zaliczkowe. + +```python +advance_xml = ( + FA3InvoiceBuilder() + .header(system_info="advance guide") + .seller( + name="Demo Developer S.A.", + tax_id="5261040828", + country_code="PL", + address_line_1="Sadowa 1", + ) + .buyer( + name="Demo Buyer", + tax_id="5250001009", + country_code="PL", + address_line_1="Kwiatowa 2", + ) + .advance() + .issue_date(date(2026, 8, 1)) + .invoice_number("FZ/1/2026") + .order(declared_total=Decimal("324000.00")) + .add_line( + name="Rezerwacja mieszkania", + quantity=Decimal("1"), + unit_price_net=Decimal("300000.00"), + vat_rate=VatRate.VAT_8, + gross_amount=Decimal("324000.00"), + ) + .done() + .payment() + .via("bank_transfer") + .already_paid(date(2026, 8, 1)) + .done() + .done() + .to_xml() +) +``` + +```python +settlement_xml = ( + FA3InvoiceBuilder() + .header(system_info="settlement guide") + .seller( + name="Demo Developer S.A.", + tax_id="5261040828", + country_code="PL", + address_line_1="Sadowa 1", + ) + .buyer( + name="Demo Buyer", + tax_id="5250001009", + country_code="PL", + address_line_1="Kwiatowa 2", + ) + .settlement() + .issue_date(date(2026, 9, 1)) + .invoice_number("FR/1/2026") + .rows() + .add_line( + name="Rozliczenie mieszkania", + quantity=Decimal("1"), + unit_price_net=Decimal("185185.19"), + vat_rate=VatRate.VAT_8, + ) + .done() + .advance() + .add_invoice_reference(ksef_id="5261040828-20260801-ABCDEF-ABCDEF-FF") + .done() + .payment() + .via("bank_transfer") + .due_on(date(2026, 9, 15)) + .done() + .done() + .to_xml() +) +``` + +## Dodaj szczegóły root + +Szczegóły root są poza body faktury i możesz je dodać przed albo po ścieżce +body. + +| Potrzeba | Metoda fluent | Metoda modelowa | +| --- | --- | --- | +| Gotowy model nagłówka, sprzedawcy, nabywcy, stopki albo załącznika | Użyj buildera fluent, gdy chcesz edytować pola. | `header_model()`, `seller_model()`, `buyer_model()`, `footer_model()`, `attachment_model()` | +| Podmiot trzeci | `third_party(...)` | `add_third_party_model()`, `replace_third_parties()` | +| Stopka rejestrowa albo nota prawna | `footer().add_information().add_registry().done()` | `footer_model()` | +| Załącznik z blokami danych albo tabelami | `attachment().build_data_block()...done().done()` | `attachment_model()` | + +```python +builder = ( + builder + .third_party( + name="Additional buyer", + tax_id="3333333333", + address_country_code="PL", + address_line_1="Polna 3", + role="additional_buyer", + share_percentage=Decimal("50"), + ) + .footer() + .add_information("Faktura wygenerowana elektronicznie.") + .add_registry(krs="0000123456", regon="123456789") + .done() + .attachment() + .build_data_block() + .set_header("Specyfikacja dostawy") + .add_paragraph("Towar sprawdzono przed wysyłką.") + .build_table() + .set_columns(["txt", "decimal"], names=["Usługa", "Kwota netto"]) + .add_row(["Consulting", "1000.00"]) + .done() + .done() + .done() +) +``` + +## Wygeneruj XML + +Builder może zwrócić trzy postacie faktury: + +- `build()` zwraca `KsefInvoice`. +- `to_spec()` zwraca wygenerowany model schemy FA(3) `Faktura`. +- `to_xml()` zwraca XML jako string. + +```python +from pathlib import Path + +invoice = builder.build() +spec = builder.to_spec() +xml_text = builder.to_xml() +Path("invoice.xml").write_text(xml_text, encoding="utf-8") +``` + +Do sesji online albo batch przekaż bajty XML. + +```python +from ksef2 import FormSchema + +invoice_xml = builder.to_xml().encode("utf-8") + +with auth.online_session(form_code=FormSchema.FA3) as session: + sent = session.send_invoice(invoice_xml=invoice_xml) + print(sent.reference_number) +``` + +## Zapisz i odtwórz draft + +Niepełny stan buildera zapisuj przez `dump_state()` albo `dump_state_json()`. +Później odtworzysz go przez `from_state()` albo `from_state_json()`. + +```python +from ksef2.fa3 import FA3InvoiceBuilder, KsefInvoiceDraft + +draft_json = builder.dump_state_json(indent=2) +draft = KsefInvoiceDraft.model_validate_json(draft_json) +restored = FA3InvoiceBuilder.from_state(draft) +``` + +Jeżeli masz już kompletny `KsefInvoice`, otwórz go ponownie w builderze przez +`FA3InvoiceBuilder.from_invoice(invoice)`. + +## Rozwiąż typowe błędy + +| Błąd | Znaczenie | Naprawa | +| --- | --- | --- | +| `Invoice header is required but not set.` | Root builder nie ma nagłówka. | Wywołaj `header()` albo `header_model()` przed `build()`, `to_spec()` albo `to_xml()`. | +| `Invoice seller is required but not set.` | Brakuje sprzedawcy. | Wywołaj `seller()` albo `seller_model()`. | +| `Invoice buyer is required but not set.` | Brakuje nabywcy. | Wywołaj `buyer()` albo `buyer_model()`. | +| `Invoice body is required but not set.` | Nie wybrano i nie zakończono ścieżki body. | Wywołaj selektor, np. `standard()` albo `correction()`, uzupełnij wymagane pola i wróć przez `.done()`. | +| `Provide either unit_price_net or unit_price_gross, not both.` | Pozycja ma niejednoznaczną cenę. | Przekaż tylko jedną cenę jednostkową i pozwól builderowi wyliczyć sumy. | +| `... details are empty. Set at least one field before calling done().` | Otworzono opcjonalną sekcję, ale nie dodano danych. | Dodaj dane sekcji przed `.done()` albo nie twórz tej sekcji. | + +## Zalecany przepływ + + + +1. Utwórz `FA3InvoiceBuilder()` i ustaw nagłówek, sprzedawcę oraz nabywcę. + +2. Wybierz ścieżkę body: `standard()`, `correction()`, `advance()` albo inny + selektor pasujący do dokumentu biznesowego. + +3. Dodaj sekcje zagnieżdżone, takie jak `rows()`, `payment()`, `annotations()`, + `correction()`, `order()`, `footer()` albo `attachment()`. + +4. Użyj `build()` dla publicznego modelu podczas walidacji i `to_xml()` dla + ścieżki wysyłki. + +5. Zakoduj XML do bajtów przed przekazaniem go do API sesji albo batch. + + + +## Powiązane strony + + + + + diff --git a/docs/pl/how-to-guides/client-setup.mdx b/docs/pl/how-to-guides/client-setup.mdx new file mode 100644 index 0000000..1cf333b --- /dev/null +++ b/docs/pl/how-to-guides/client-setup.mdx @@ -0,0 +1,194 @@ +--- +title: Skonfiguruj klienta +description: Utwórz klienta sync albo async, skonfiguruj transport i wybierz uwierzytelnione gałęzie. +--- + +import { Aside, LinkCard, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; + +Klient główny `Client` przechowuje konfigurację transportu HTTP i udostępnia +publiczne, nieuwierzytelnione gałęzie. Uwierzytelnij się raz dla kontekstu KSeF, +a potem przekaż uwierzytelnionego klienta do kodu przepływu. + +## Wybierz sync albo async + + + + +```python +from ksef2 import Client, Environment + +with Client(Environment.TEST) as client: + auth = client.authentication.with_test_certificate(nip="5261040828") +``` + + + + +```python +from ksef2 import AsyncClient, Environment + +async with AsyncClient(Environment.TEST) as client: + auth = await client.authentication.with_test_certificate(nip="5261040828") +``` + + + + +Poza lokalnymi przepływami TEST używaj `Environment.DEMO` albo +`Environment.PRODUCTION`. + +## Zarządzaj cyklem życia + +Klient główny przechowuje zasoby HTTP zarządzane przez SDK. W skryptach i +zadaniach preferuj context manager: + +```python +from ksef2 import Client, Environment + +with Client(Environment.TEST) as client: + auth = client.authentication.with_test_certificate(nip="5261040828") +``` + +Jeżeli framework oczekuje zależności opartej o `yield`, umieść `yield` w +funkcji zależności i zamknij klienta w `finally`: + +```python +from collections.abc import Iterator + +from ksef2 import Client, Environment + + +def get_client() -> Iterator[Client]: + client = Client(Environment.TEST) + try: + yield client + finally: + client.close() +``` + +Aplikacje async używają tej samej granicy przez `async with`: + +```python +from ksef2 import AsyncClient, Environment + +async with AsyncClient(Environment.TEST) as client: + auth = await client.authentication.with_test_certificate(nip="5261040828") +``` + +Sesje online i batch też są granicami cyklu życia. Używaj context managera +sesji, żeby SDK zamknęło zdalną sesję po wyjściu z bloku: + +```python +from ksef2 import FormSchema + +with auth.online_session(form_code=FormSchema.FA3) as session: + status = session.send_invoice_and_wait(invoice_xml=invoice_xml) +``` + +Gdy dane uwierzytelniające są zapisane w profilu kompatybilnym z CLI, utwórz +klienta głównego dla środowiska profilu i uwierzytelnij się przez +`with_profile()`: + +```python +from ksef2 import Client, Environment + +client = Client(Environment.PRODUCTION) +auth = client.authentication.with_profile("prod-token") +``` + +## Publiczne gałęzie klienta głównego + +Klient główny przydaje się przed uwierzytelnieniem: + +```python +certificates = client.encryption.get_certificates() +providers = client.peppol.query() +``` + +Gałąź tylko dla TEST także jest na kliencie głównym: + +```python +client.testdata.create_subject( + nip="5261040828", + subject_type="vat_group", + description="Sandbox company", +) +``` + +## Gałęzie uwierzytelnione + +Po uwierzytelnieniu użyj gałęzi pasującej do zadania: + +```python +invoices = auth.invoices +batch = auth.batch +tokens = auth.tokens +permissions = auth.permissions +certificates = auth.certificates +limits = auth.limits +sessions = auth.sessions +invoice_sessions = auth.invoice_sessions +``` + + + +## Zalecany przepływ + + + +1. Odczytaj środowisko i ustawienia transportu na granicy aplikacji. + +2. Utwórz jednego klienta głównego dla wybranego środowiska KSeF. + +3. Używaj gałęzi głównych tylko do publicznych odczytów albo przygotowania + danych TEST. + +4. Uwierzytelnij się raz dla kontekstu, który wykonuje operację. + +5. Przekaż uwierzytelnionego klienta do przepływów faktur, tokenów, uprawnień, + certyfikatów, limitów i sesji. + + + +## Referencja + + + + + + + + diff --git a/docs/pl/how-to-guides/configure-certificate-store.mdx b/docs/pl/how-to-guides/configure-certificate-store.mdx new file mode 100644 index 0000000..9b26254 --- /dev/null +++ b/docs/pl/how-to-guides/configure-certificate-store.mdx @@ -0,0 +1,148 @@ +--- +title: Skonfiguruj magazyn certyfikatów +description: Skonfiguruj cache certyfikatów SDK używany przez uwierzytelnianie tokenem, sesje i eksporty faktur. +--- + +import { Aside, LinkCard, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; + +SDK przechowuje publiczne certyfikaty szyfrowania KSeF na kliencie głównym. +Uwierzytelnianie tokenem, sesje online i batch oraz eksporty faktur używają tego +magazynu, gdy muszą zaszyfrować materiał klucza dla KSeF. + +Użyj domyślnego magazynu pamięciowego dla skryptów i workerów, które mogą +odświeżać certyfikaty z KSeF. Przekaż własny magazyn, gdy certyfikaty muszą być +współdzielone między procesami albo zapisane w magazynie aplikacji. + +## Użyj domyślnego magazynu + +`Client` i `AsyncClient` tworzą `CertificateStore` automatycznie. Domyślny +magazyn odświeża ważne certyfikaty po 24 godzinach i odświeża je natychmiast, +gdy brakuje wymaganego zastosowania certyfikatu. + +Ustaw inny interwał odświeżania podczas tworzenia klienta głównego: + + + + +```python +from datetime import timedelta + +from ksef2 import CertificateStore, Client, Environment + +store = CertificateStore(refresh_after=timedelta(hours=6)) + +with Client(Environment.PRODUCTION, certificate_store=store) as client: + auth = client.authentication.with_profile("prod-token") +``` + + + + +```python +from datetime import timedelta + +from ksef2 import AsyncClient, CertificateStore, Environment + +store = CertificateStore(refresh_after=timedelta(hours=6)) + +async with AsyncClient(Environment.PRODUCTION, certificate_store=store) as client: + auth = await client.authentication.with_profile("prod-token") +``` + + + + +Użyj `refresh_after=None` tylko dla krótkotrwałych klientów, w których +zachowanie "pobierz raz" jest zamierzone: + +```python +from ksef2 import CertificateStore + +store = CertificateStore(refresh_after=None) +``` + + + +## Przekaż własny magazyn + +Własne magazyny implementują `CertificateStoreProtocol`. SDK odpowiada za +zdalne pobieranie, a magazyn odpowiada za trwałość, wybór ważnego certyfikatu i +decyzje o świeżości. + +```python +from collections.abc import Iterable +from datetime import datetime + +from ksef2 import CertificateStoreProtocol, Client, Environment +from ksef2.domain.models.encryption import ( + CertUsage, + CertUsageEnum, + PublicKeyCertificate, +) + + +class DatabaseCertificateStore: + def load(self, certs: Iterable[PublicKeyCertificate]) -> None: + """Zastąp zapisane certyfikaty po pobraniu ich przez SDK.""" + ... + + def get_valid( + self, + usage: CertUsage | CertUsageEnum | str, + ) -> PublicKeyCertificate: + """Zwróć aktualnie ważny certyfikat dla wymaganego zastosowania.""" + ... + + def needs_refresh( + self, + usage: CertUsage | CertUsageEnum | str, + *, + at: datetime | None = None, + ) -> bool: + """Zwróć True, gdy SDK powinno pobrać certyfikaty z KSeF.""" + ... + + +store: CertificateStoreProtocol = DatabaseCertificateStore() +client = Client(Environment.PRODUCTION, certificate_store=store) +``` + +## Zalecany przepływ + + + +1. Zacznij od domyślnego `CertificateStore`. + +2. Ustaw `refresh_after`, gdy aplikacja ma bardziej rygorystyczną politykę + rotacji certyfikatów albo startu. + +3. Implementuj `CertificateStoreProtocol` tylko wtedy, gdy certyfikaty muszą + przetrwać restart procesu albo być współdzielone przez wiele workerów. + +4. Zdalne pobieranie z KSeF zostaw klientowi SDK, a trwałość danych magazynowi. + + + +## Powiązane strony + + + + + + diff --git a/docs/pl/how-to-guides/configure-permissions.mdx b/docs/pl/how-to-guides/configure-permissions.mdx new file mode 100644 index 0000000..99ba91d --- /dev/null +++ b/docs/pl/how-to-guides/configure-permissions.mdx @@ -0,0 +1,215 @@ +--- +title: Skonfiguruj uprawnienia +description: Nadawaj, wyszukuj, odbieraj i monitoruj uprawnienia KSeF z ksef2. +--- + +import { Aside, LinkCard, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; + +Użyj `auth.permissions`, gdy uwierzytelniony kontekst ma nadawać, sprawdzać albo +cofać uprawnienia KSeF. Zmiany uprawnień są asynchroniczne: metody grant i +revoke najpierw zwracają referencję operacji. + +## Nadaj uprawnienia + +Wybierz metodę nadania pasującą do docelowego podmiotu. Utrzymuj zakres +uprawnień tak mały, jak pozwala na to przepływ. + + + + +```python +operation = auth.permissions.grant_person( + subject_type="pesel", + subject_value="90010112345", + permissions=["invoice_read"], + description="Read invoices", + first_name="Jan", + last_name="Kowalski", +) + +# GrantPermissionsResponse +# { +# "reference_number": "20260625-PERM-..." +# } +``` + + + + +```python +from ksef2.models import EntityPermission + +operation = auth.permissions.grant_entity( + subject_value="1234567890", + permissions=[ + EntityPermission(type="invoice_read", can_delegate=False), + ], + description="Accounting office read access", + entity_name="Accounting Sp. z o.o.", +) +``` + + + + +```python +operation = auth.permissions.grant_authorization( + subject_type="nip", + subject_value="1234567890", + permission="self_invoicing", + description="Self-invoicing agreement", + entity_name="Partner Sp. z o.o.", +) +``` + + + + +## Sprawdź status operacji + +Używaj zwróconego `reference_number`, aż KSeF zgłosi wynik operacji uprawnień. + +```python +status = auth.permissions.get_operation_status( + reference_number=operation.reference_number, +) + +# PermissionOperationStatusResponse +# { +# "status": { +# "code": 200, +# "description": "Completed" +# } +# } +``` + + + +## Wyszukaj uprawnienia + +Metody query mają osobne kształty, ponieważ KSeF rozróżnia rekordy personal, +person, entity, authorization, EU entity, subordinate entity i subunit. + + + + +```python +from ksef2.models import PersonalPermissionsQuery + +page = auth.permissions.query_personal( + query=PersonalPermissionsQuery( + permission_types=["invoice_read"], + permission_state="active", + ), +) + +for permission in page.permissions: + print(permission.id, permission.permission_type, permission.permission_state) +``` + + + + +```python +from ksef2.models import EntityPermissionsQuery + +page = auth.permissions.query_entities( + query=EntityPermissionsQuery(context_type="nip", context_value="5261040828"), +) + +for permission in page.permissions: + print(permission.id, permission.permission_type, permission.can_delegate) +``` + + + + +```python +from ksef2.models import AuthorizationPermissionsQuery + +page = auth.permissions.query_authorizations( + query=AuthorizationPermissionsQuery( + query_type="granted", + permission_types=["self_invoicing"], + ), +) + +for grant in page.authorization_grants: + print(grant.id, grant.authorization_scope, grant.authorized_entity_value) +``` + + + + +## Cofnij uprawnienia + +Użyj identyfikatora uprawnienia zwróconego przez zapytanie. Cofnięcie też zwraca +referencję operacji. + +```python +operation = auth.permissions.revoke_common(permission_id="permission-id") +status = auth.permissions.get_operation_status( + reference_number=operation.reference_number, +) +print(status.status.code, status.status.description) +``` + +Dla nadań autoryzacji użyj metody cofania autoryzacji: + +```python +operation = auth.permissions.revoke_authorization( + permission_id="authorization-id", +) +``` + +## Sprawdź uprawnienie do załączników + +```python +status = auth.permissions.get_attachment_permission_status() + +# AttachmentPermissionStatus +# { +# "is_attachment_allowed": true, +# "revoked_date": null +# } +``` + +## Zalecany przepływ + + + +1. Nadaj najmniejszy zestaw uprawnień wymagany przez docelowy podmiot. + +2. Zapisz `reference_number` operacji. + +3. Sprawdź status operacji przed pokazaniem uprawnienia jako aktywnego. + +4. Wyszukaj uprawnienia, aby zebrać identyfikatory do audytu albo cofnięcia. + +5. Cofnij po identyfikatorze uprawnienia, gdy dostęp ma się zakończyć, a potem + sprawdź status operacji cofnięcia. + + + +## Następne przepływy + + + + + + diff --git a/docs/pl/how-to-guides/download-invoices.mdx b/docs/pl/how-to-guides/download-invoices.mdx new file mode 100644 index 0000000..4f5ff28 --- /dev/null +++ b/docs/pl/how-to-guides/download-invoices.mdx @@ -0,0 +1,201 @@ +--- +title: Pobierz faktury +description: Pobieraj przetworzony XML faktur bezpośrednio albo przez szyfrowane paczki eksportu ksef2. +--- + +import { Aside, LinkCard, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; + +Użyj `auth.invoices`, gdy potrzebujesz treści faktury po przetworzeniu jej przez +KSeF. Przykłady poniżej zakładają, że masz już uwierzytelnionego klienta +`auth`. + +## Pobierz jedną fakturę + +Jeśli masz już numer KSeF, bezpośrednie pobranie jest najkrótszą ścieżką. + +```python +from pathlib import Path + +ksef_number = "1234567890-20260625-..." + +xml_bytes = auth.invoices.download_invoice(ksef_number=ksef_number) +Path("invoice.xml").write_bytes(xml_bytes) +``` + +Jeśli faktura została dopiero wysłana, KSeF może potrzebować czasu, zanim +przetworzony XML będzie dostępny do pobrania. Użyj helpera oczekującego, gdy +przepływ ma blokować do dostępności dokumentu. + +```python +from pathlib import Path + +ksef_number = "1234567890-20260625-..." + +xml_bytes = auth.invoices.wait_for_invoice_download( + ksef_number=ksef_number, + timeout=120.0, + poll_interval=2.0, +) +Path("invoice.xml").write_bytes(xml_bytes) +``` + +## Zbuduj filtr eksportu + +Eksportów używaj do większych pobrań. Eksporty korzystają z `InvoicesFilter`, +czyli tego samego kształtu filtra co zapytania o metadane. + +```python +from datetime import datetime, timedelta, timezone + +from ksef2.models import InvoicesFilter + +now = datetime.now(tz=timezone.utc) + +filters = InvoicesFilter.for_buyer( + date_type="permanent_storage", + date_from=now - timedelta(days=1), + date_to=now, + restrict_to_permanent_storage_hwm_date=True, +) +``` + + + +## Wyeksportuj wiele faktur + +Zaplanuj eksport, poczekaj na paczkę, a potem pobierz odszyfrowane części ZIP. + + + + +```python +from pathlib import Path + +export = auth.invoices.schedule_export(filters=filters) + +# ExportHandle(reference_number="...", aes_key=, iv=) +# Zachowaj ten obiekt jako prywatny. Zawiera materiał deszyfrujący. + +package = auth.invoices.wait_for_export_package( + reference_number=export.reference_number, + timeout=300.0, +) + +# InvoicePackage +# { +# "invoice_count": 3, +# "size": 14820, +# "is_truncated": false, +# "last_permanent_storage_date": "2026-06-25T09:58:21Z", +# "permanent_storage_hwm_date": "2026-06-25T10:00:00Z", +# "parts": [ +# { +# "ordinal_number": 1, +# "part_name": "package-1.zip", +# "expiration_date": "2026-06-26T10:00:00Z" +# } +# ] +# } + +saved_paths = auth.invoices.fetch_package( + package=package, + export=export, + target_directory=Path("downloads"), +) + +for path in saved_paths: + print(path) +``` + + + + +```python +export = auth.invoices.schedule_export(filters=filters) +package = auth.invoices.wait_for_export_package( + reference_number=export.reference_number, + timeout=300.0, +) + +zip_parts = auth.invoices.fetch_package_bytes(package=package, export=export) + +for zip_part in zip_parts: + print(len(zip_part)) +``` + + + + +```python +zip_parts = auth.invoices.export_and_download( + filters=filters, + timeout=300.0, + poll_interval=2.0, +) + +for zip_part in zip_parts: + print(len(zip_part)) +``` + + + + + + + + +## Po wysyłce faktur + +Trzymaj wysyłkę, przetwarzanie i pobieranie jako osobne fazy. + + + +1. Wyślij XML faktury przez sesję online albo wsadową. + +2. Polluj status sesji albo faktury do końcowego zaakceptowanego wyniku. + +3. Zapisz zwrócone wartości `ksef_number`. + +4. Pobierz jeden przetworzony dokument XML po `ksef_number` albo zbuduj filtr + eksportu dla większego okna czasu. + + + +## Następne przepływy + + + + + + + + diff --git a/docs/pl/how-to-guides/get-status-and-upo.mdx b/docs/pl/how-to-guides/get-status-and-upo.mdx new file mode 100644 index 0000000..176bf92 --- /dev/null +++ b/docs/pl/how-to-guides/get-status-and-upo.mdx @@ -0,0 +1,257 @@ +--- +title: Sprawdź status i UPO +description: Sprawdzaj status sesji online i batch, listuj faktury sesji i pobieraj dokumenty UPO. +--- + +import { Aside, LinkCard, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; + +Użyj tej strony po wysłaniu XML faktury. Przykłady zakładają, że masz już +uwierzytelnionego klienta `auth`. + +Wywołania statusu zwracają modele SDK. Wywołania UPO zwracają bajty XML. + +## Status faktury online + +Przy wysyłce online `send_invoice()` zwraca referencję faktury w sesji. Użyj tej +referencji do pollingu wyniku faktury i pobrania UPO faktury. + +```python +from pathlib import Path + +from ksef2 import FormSchema + +with auth.online_session(form_code=FormSchema.FA3) as session: + sent = session.send_invoice(invoice_xml=invoice_xml) + + invoice_status = session.wait_for_invoice_ready( + invoice_reference_number=sent.reference_number, + timeout=120.0, + poll_interval=2.0, + ) + + # SessionInvoiceStatusResponse + # { + # "ordinal_number": 1, + # "reference_number": "20260625-ABCD-EF1234567890", + # "invoice_number": "FV/42/2026", + # "ksef_number": "1234567890-20260625-...", + # "status": { + # "code": 200, + # "description": "Processed" + # } + # } + + upo_xml = session.get_invoice_upo_by_reference( + invoice_reference_number=sent.reference_number, + ) + Path("upo.xml").write_bytes(upo_xml) +``` + +W tym samym bloku sesji, jeśli masz już numer KSeF, możesz pobrać UPO faktury +po numerze KSeF: + +```python +ksef_number = invoice_status.ksef_number +if ksef_number is None: + raise RuntimeError("KSeF did not assign an invoice number.") + +upo_xml = session.get_invoice_upo_by_ksef_number( + ksef_number=ksef_number, +) +``` + + + +## Strony sesji online + +Użyj stron sesji, gdy chcesz sprawdzić wszystko, co wysłano w sesji online, a +nie jedną referencję faktury. + + + + +```python +page = session.list_invoices(page_size=100) + +for invoice in page.invoices: + print(invoice.reference_number, invoice.ksef_number) +``` + + + + +```python +page = session.list_failed_invoices(page_size=100) + +for invoice in page.invoices: + print(invoice.reference_number, invoice.status.code, invoice.status.details) +``` + + + + +## Status batch i UPO + +Przy wysyłce batch zachowaj zwrócony `BatchSessionResumeState`. Serwis batch może +pollować po stanie albo po samym numerze referencyjnym sesji. + +```python +from pathlib import Path + +final_status = auth.batch.wait_for_completion( + session=state, + timeout=300.0, + poll_interval=2.0, +) + +# SessionStatusResponse +# { +# "status": { +# "code": 200, +# "description": "Processed" +# }, +# "invoice_count": 10, +# "successful_invoice_count": 9, +# "failed_invoice_count": 1, +# "upo": { +# "pages": [ +# { +# "reference_number": "upo-page-reference", +# "download_url_expiration_date": "2026-06-26T10:00:00Z" +# } +# ] +# } +# } + +if final_status.upo is not None and final_status.upo.pages: + upo_reference_number = final_status.upo.pages[0].reference_number + upo_xml = auth.batch.get_upo( + session=state, + upo_reference_number=upo_reference_number, + ) + Path("batch-upo.xml").write_bytes(upo_xml) +``` + + + +## Listuj faktury przyjęte i odrzucone w batchu + +Faktury przyjęte i odrzucone są na osobnych stronach. Przeczytaj oba zestawy +przy uzgadnianiu batcha. + + + + +```python +page = auth.batch.list_invoices(session=state, page_size=100) + +for invoice in page.invoices: + print(invoice.invoice_file_name, invoice.ksef_number, invoice.status.description) +``` + + + + +```python +page = auth.batch.list_failed_invoices(session=state, page_size=100) + +for invoice in page.invoices: + print(invoice.invoice_file_name, invoice.status.code, invoice.status.details) +``` + + + + +Gdy strona ma `continuation_token`, przekaż go do następnego wywołania: + +```python +page = auth.batch.list_invoices(session=state, page_size=100) + +while page.continuation_token is not None: + page = auth.batch.list_invoices( + session=state, + page_size=100, + continuation_token=page.continuation_token, + ) +``` + +## Znajdź sesje po restarcie + +Użyj `auth.invoice_sessions`, gdy musisz znaleźć sesje online albo batch po +zakończeniu pierwotnego procesu wysyłki. + + + + +```python +page = auth.invoice_sessions.query( + session_type="online", + statuses=["in_progress", "succeeded"], +) + +for item in page.sessions: + print(item.reference_number, item.status.code, item.total_invoice_count) +``` + + + + +```python +for page in auth.invoice_sessions.all(session_type="batch"): + for item in page.sessions: + print(item.reference_number, item.status.description) +``` + + + + +## Zalecany przepływ + + + +1. Zapisz referencje sesji i faktur podczas wysyłki. + +2. Polluj konkretną fakturę, sesję online albo sesję batch zgodnie z pytaniem, + na które odpowiadasz. + +3. Zapisz szczegóły faktur przyjętych i odrzuconych. + +4. Pobierz XML UPO i zapisz go razem z danymi audytowymi. + +5. Użyj `auth.invoice_sessions`, żeby odzyskać referencje sesji po restarcie + procesu. + + + +## Następne przepływy + + + + + + + + diff --git a/docs/pl/how-to-guides/inspect-encryption-certificates.mdx b/docs/pl/how-to-guides/inspect-encryption-certificates.mdx new file mode 100644 index 0000000..c2493c5 --- /dev/null +++ b/docs/pl/how-to-guides/inspect-encryption-certificates.mdx @@ -0,0 +1,123 @@ +--- +title: Sprawdź certyfikaty szyfrowania +description: Odczytuj publiczne certyfikaty szyfrowania KSeF używane przez zaszyfrowane przepływy faktur i eksportów. +--- + +import { Aside, LinkCard, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; + +Użyj `client.encryption`, gdy potrzebujesz kontroli startowej, diagnostyki albo +własnego cache certyfikatów. Większość przepływów faktur ładuje publiczne +certyfikaty szyfrowania automatycznie. + +## Pobierz certyfikaty + +`client.encryption` jest gałęzią klienta głównego, więc nie wymaga klienta +uwierzytelnionego. + + + + +```python +certificates = client.encryption.get_certificates() + +# PublicKeyCertificate +# { +# "public_key_id": "12345", +# "certificate_id": "abcde", +# "valid_from": "2026-06-01T00:00:00Z", +# "valid_to": "2026-12-01T00:00:00Z", +# "usage": ["ksef_token_encryption", "symmetric_key_encryption"] +# } + +for certificate in certificates: + print(certificate.public_key_id, certificate.usage, certificate.valid_to) +``` + + + + +```python +certificates = client.encryption.get_certificates( + usage=["symmetric_key_encryption"], +) + +for certificate in certificates: + print(certificate.public_key_id, certificate.valid_to) +``` + + + + +```python +certificates = client.encryption.get_certificates( + usage=["ksef_token_encryption"], +) + +for certificate in certificates: + print(certificate.public_key_id, certificate.valid_to) +``` + + + + +## Wstępnie załaduj przed szyfrowanymi przepływami + +Wysokopoziomowe helpery sesji online, batch, uwierzytelniania tokenem i eksportu +używają tych publicznych certyfikatów, gdy szyfrują lokalny materiał klucza dla +KSeF. Wstępne ładowanie jest przydatne, gdy chcesz diagnostyki startowej zanim +worker zacznie przyjmować zadania. + +```python +required_usage = "symmetric_key_encryption" +certificates = client.encryption.get_certificates(usage=[required_usage]) + +if not certificates: + raise RuntimeError(f"No KSeF certificate supports {required_usage}.") +``` + + + +## Zalecany przepływ + + + +1. Domyślnie pozwól przepływom faktur ładować certyfikaty leniwie. + +2. Dodaj kontrolę startową tylko wtedy, gdy brak certyfikatu ma zatrzymać + proces przed przyjęciem pracy. + +3. Sprawdź zastosowanie wymagane przez przepływ: szyfrowanie tokenów albo + szyfrowanie kluczy symetrycznych. + +4. Alarmuj i ponów później, jeśli nie ma ważnego certyfikatu. + + + +## Następne przepływy + + + + + + + + diff --git a/docs/pl/how-to-guides/manage-certificates.mdx b/docs/pl/how-to-guides/manage-certificates.mdx new file mode 100644 index 0000000..fb51ad2 --- /dev/null +++ b/docs/pl/how-to-guides/manage-certificates.mdx @@ -0,0 +1,154 @@ +--- +title: Zarządzaj certyfikatami +description: Sprawdzaj limity certyfikatów, rejestruj certyfikaty, wyszukuj wydane certyfikaty i cofaj je. +--- + +import { Aside, LinkCard, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; + +Użyj `auth.certificates` do rejestracji, wyszukiwania, pobierania i cofania +certyfikatów KSeF. SDK wysyła CSR i żądania cyklu życia. Narzędzia +certyfikatowe nadal odpowiadają za generowanie i przechowywanie kluczy +prywatnych. + +## Sprawdź limity i dane rejestracji + +```python +limits = auth.certificates.get_limits() + +# CertificateLimitsResponse +# { +# "can_request": true, +# "enrollment_limit": 10, +# "enrollment_remaining": 9, +# "certificate_limit": 10, +# "certificate_remaining": 8 +# } + +subject = auth.certificates.get_enrollment_data() + +# CertificateEnrollmentData +# { +# "common_name": "Example subject", +# "iso_country_code": "PL", +# "organization_identifier": "VATPL-5261040828" +# } +``` + +Użyj danych rejestracji podczas budowania CSR w narzędziach certyfikatowych. + +## Zarejestruj certyfikat + +Wyślij CSR i zapisz zwróconą referencję rejestracji. + +```python +csr = """-----BEGIN CERTIFICATE REQUEST----- +... +-----END CERTIFICATE REQUEST-----""" + +enrollment = auth.certificates.enroll( + certificate_name="billing-service", + certificate_type="authentication", + csr=csr, +) + +# CertificateEnrollmentResponse +# { +# "reference_number": "20260625-CERT-...", +# "timestamp": "2026-06-25T10:00:00Z" +# } +``` + +Następnie sprawdź status rejestracji: + +```python +status = auth.certificates.get_enrollment_status( + reference_number=enrollment.reference_number, +) + +# CertificateEnrollmentStatusResponse +# { +# "status_code": 200, +# "status_description": "Completed", +# "certificate_serial_number": "0123456789ABCDEF" +# } +``` + + + +## Wyszukaj, pobierz i cofnij + + + + +```python +for certificate in auth.certificates.all(status="active"): + print(certificate.serial_number, certificate.name, certificate.valid_to) +``` + + + + +```python +result = auth.certificates.retrieve( + certificate_serial_numbers=["0123456789ABCDEF"], +) + +for certificate in result.certificates: + print(certificate.serial_number, certificate.certificate_type) +``` + + + + +```python +auth.certificates.revoke( + certificate_serial_number="0123456789ABCDEF", + reason="key_compromise", +) +``` + + + + +## Zalecany przepływ + + + +1. Sprawdź limity certyfikatów i rejestracji. + +2. Pobierz dane podmiotu do rejestracji. + +3. Wygeneruj klucz prywatny i CSR poza SDK. + +4. Wyślij rejestrację i zapisz numer referencyjny. + +5. Odpytuj status, pobierz certyfikat i zapisz go z kluczem prywatnym. + +6. Cofnij certyfikaty, które nie są już ważne albo których klucz prywatny mógł + zostać naruszony. + + + +## Następne przepływy + + + + + + diff --git a/docs/pl/how-to-guides/manage-limits.mdx b/docs/pl/how-to-guides/manage-limits.mdx new file mode 100644 index 0000000..1da9e5d --- /dev/null +++ b/docs/pl/how-to-guides/manage-limits.mdx @@ -0,0 +1,150 @@ +--- +title: Zarządzaj limitami +description: Odczytuj i nadpisuj limity kontekstu, podmiotu i API rate limitów KSeF przez ksef2. +--- + +import { Aside, LinkCard, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; + +Użyj `auth.limits`, żeby sprawdzić efektywne limity, które KSeF stosuje do +bieżącego uwierzytelnionego kontekstu. Metod nadpisywania używaj tylko w jawnych +przepływach administracyjnych. + +## Odczytaj efektywne limity + +Odczytaj bieżące limity przed wyborem wielkości batcha, częstotliwości pollingu +albo przepływów wystawiania certyfikatów. + + + + +```python +context = auth.limits.get_context_limits() + +# ContextLimits +# { +# "online_session": { +# "max_invoice_size_mb": 10, +# "max_invoice_with_attachment_size_mb": 20, +# "max_invoices": 100 +# }, +# "batch_session": { +# "max_invoice_size_mb": 10, +# "max_invoice_with_attachment_size_mb": 20, +# "max_invoices": 1000 +# } +# } + +print(context.online_session.max_invoices) +print(context.batch_session.max_invoice_size_mb) +``` + + + + +```python +subject = auth.limits.get_subject_limits() + +print(subject.certificate) +print(subject.enrollment) +``` + + + + +```python +rate = auth.limits.get_api_rate_limits() + +print(rate.invoice_send.per_minute) +print(rate.invoice_metadata.per_minute) +print(rate.invoice_download.per_hour) +``` + + + + +## Nadpisz limity sesji + +Nadpisań limitów sesji używaj tylko wtedy, gdy uwierzytelniony kontekst może +zarządzać limitami, a zmiana jest częścią kontrolowanego testu albo procedury +administracyjnej. + +```python +from ksef2.models import ContextLimits, SessionLimits + +limits = ContextLimits( + online_session=SessionLimits( + max_invoice_size_mb=10, + max_invoice_with_attachment_size_mb=20, + max_invoices=100, + ), + batch_session=SessionLimits( + max_invoice_size_mb=10, + max_invoice_with_attachment_size_mb=20, + max_invoices=1000, + ), +) + +auth.limits.set_session_limits(limits=limits) +``` + +Zresetuj nadpisanie po zakończeniu testu albo zmiany tymczasowej: + +```python +auth.limits.reset_session_limits() +``` + +## Użyj produkcyjnych domyślnych rate limitów + +Użyj `set_production_rate_limits()`, gdy środowisko podobne do TEST ma skopiować +produkcyjne domyślne rate limity API. + +```python +auth.limits.set_production_rate_limits() + +rate = auth.limits.get_api_rate_limits() +print(rate.invoice_send.per_minute) +``` + + + +## Zalecany przepływ + + + +1. Odczytaj efektywne limity przed wyborem wielkości batcha, strategii uploadu + albo częstotliwości pollingu. + +2. Zachowuj produkcyjne domyślne wartości, chyba że kontrolowany test albo + przepływ administratora wymaga nadpisania. + +3. Stosuj nadpisania z jawnego kodu operacyjnego. + +4. Zweryfikuj nowe limity po ich zastosowaniu. + +5. Resetuj tymczasowe nadpisania po teście albo oknie utrzymaniowym. + + + +## Następne przepływy + + + + + + diff --git a/docs/pl/how-to-guides/manage-tokens.mdx b/docs/pl/how-to-guides/manage-tokens.mdx new file mode 100644 index 0000000..e3445c3 --- /dev/null +++ b/docs/pl/how-to-guides/manage-tokens.mdx @@ -0,0 +1,140 @@ +--- +title: Zarządzaj tokenami +description: Generuj, listuj, sprawdzaj i cofaj tokeny KSeF przez ksef2. +--- + +import { Aside, LinkCard, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; + +Użyj `auth.tokens`, gdy uwierzytelniony kontekst ma tworzyć albo wycofywać +tokeny KSeF dla automatyzacji. Przykłady zakładają, że masz już +uwierzytelnionego klienta `auth`. + +## Wygeneruj token + +Wybierz najmniejszy zestaw uprawnień wymagany przez automatyzację, która będzie +używać tokena. + +```python +token = auth.tokens.generate( + permissions=["invoice_read"], + description="nightly invoice export", + timeout=60.0, + poll_interval=1.0, +) + +# GenerateTokenResponse +# { +# "reference_number": "20260625-TOKEN-...", +# "token": "eyJ..." +# } +``` + +`generate()` wysyła żądanie tokena i czeka, aż KSeF zgłosi token jako aktywny. +Zwrócone pole `token` jest sekretem, którego automatyzacja użyje później do +uwierzytelniania tokenem. + + + +## Listuj tokeny + +Użyj `list_page()` dla jednej strony albo `list_all()` dla pełnego przebiegu +audytowego. + + + + +```python +page = auth.tokens.list_page() + +# QueryTokensResponse +# { +# "continuation_token": null, +# "tokens": [ +# { +# "reference_number": "20260625-TOKEN-...", +# "description": "nightly invoice export", +# "requested_permissions": ["invoice_read"], +# "status": "active" +# } +# ] +# } + +for item in page.tokens: + print(item.reference_number, item.status, item.description) +``` + + + + +```python +for page in auth.tokens.list_all(): + for item in page.tokens: + print(item.reference_number, item.status, item.description) +``` + + + + +## Sprawdź albo cofnij jeden token + +Do późniejszych operacji cyklu życia użyj numeru referencyjnego tokena. + +```python +status = auth.tokens.status(reference_number="20260625-TOKEN-...") + +# TokenStatusResponse +# { +# "reference_number": "20260625-TOKEN-...", +# "status": "active" +# } +``` + +```python +auth.tokens.revoke(reference_number="20260625-TOKEN-...") +``` + + + +## Zalecany przepływ + + + +1. Wybierz najmniejszy zestaw uprawnień wymagany przez automatyzację. + +2. Wygeneruj token w uwierzytelnionym kontekście, który ma być jego właścicielem. + +3. Zapisz wartość tokena w magazynie sekretów, a numer referencyjny w metadanych. + +4. Listuj albo sprawdzaj referencje tokenów podczas audytów. + +5. Cofaj tokeny nieużywane, wygasłe albo naruszone. + + + +## Następne przepływy + + + + + + diff --git a/docs/pl/how-to-guides/migrate-to-1-0-0.mdx b/docs/pl/how-to-guides/migrate-to-1-0-0.mdx new file mode 100644 index 0000000..c9d95aa --- /dev/null +++ b/docs/pl/how-to-guides/migrate-to-1-0-0.mdx @@ -0,0 +1,191 @@ +--- +title: Migracja do ksef2 1.0.0 +description: Przenieś kod aplikacyjny z wersji pre-1.0 na stabilną publiczną powierzchnię SDK ksef2. +--- + +import { Aside, LinkCard, Steps } from '@astrojs/starlight/components'; + +Użyj tego przewodnika, gdy aplikacja była budowana na wersji ksef2 sprzed 1.0 i +chcesz przenieść ją na udokumentowany publiczny kontrakt 1.x. + + + +## 1. Przypnij okno aktualizacji + +Zainstaluj jawnie linię 1.x i uruchom testy integracyjne przeciw temu samemu +środowisku KSeF, którego użyje aplikacja: + +```bash +pip install "ksef2>=1.0,<2" +# albo +uv add "ksef2>=1.0,<2" +``` + +Pozostań na Pythonie 3.12 albo nowszym. + +## 2. Przenieś importy na ścieżki publiczne + +Zastąp importy z modułów internal zanim zmienisz zachowanie przepływów. + +| Ścieżka pre-1.0 albo internal | Użyj w 1.0 | +| --- | --- | +| `ksef2.core.xades` | `ksef2.xades` | +| `ksef2.core.exceptions` w obsłudze błędów aplikacji | eksporty z root `ksef2`, na przykład `KSeFApiError`, `KSeFRateLimitError` i `KSeFException` | +| `ksef2.config` dla typowej konfiguracji | eksporty z root `ksef2`, na przykład `Environment`, `TransportConfig` i `RetryConfig` | +| `ksef2.infra.schema.api.spec` | `from ksef2.raw import spec` | +| `ksef2.infra.schema.api.supp` | `from ksef2.raw import supp` | +| `ksef2.infra.mappers.*` | wysokopoziomowe modele SDK albo udokumentowane mosty pod `ksef2.raw.mappers` | +| `ksef2.endpoints.*` | wysokopoziomowe gałęzie klienta albo `client.raw` / `auth.raw` | +| stare importy buildera FA(3) albo helpery sample-invoice | `ksef2.fa3.FA3InvoiceBuilder` i publiczne modele `ksef2.fa3` | + +Jeśli import internal nie ma publicznego odpowiednika, przenieś kod do +najbliższej udokumentowanej gałęzi przepływu zamiast kopiować implementację +internal. + +## 3. Oprzyj przepływy o gałęzie uwierzytelnione + +Normalny kształt to klient root, uwierzytelnienie, a potem gałąź +uwierzytelniona: + +```python +from ksef2 import Client, Environment, FormSchema + +client = Client(Environment.TEST) +auth = client.authentication.with_token( + ksef_token="twoj-token-ksef", + nip="5261040828", +) + +with auth.online_session(form_code=FormSchema.FA3) as session: + status = session.send_invoice_and_wait( + invoice_xml=b"...", + timeout=60.0, + ) + +invoice_xml = auth.invoices.wait_for_invoice_download( + ksef_number=status.ksef_number, + timeout=120.0, +) +``` + +Używaj `auth.invoices`, `auth.tokens`, `auth.permissions`, `auth.certificates` i +`auth.limits` do pracy uwierzytelnionej. Używaj `client.encryption`, +`client.peppol` oraz dostępnego tylko w TEST `client.testdata` do pracy +publicznej albo przygotowawczej na kliencie root. + +## 4. Zaktualizuj obsługę błędów + +Łap błędy sklasyfikowane przez SDK publicznymi klasami wyjątków, a błędy +transportu trzymaj osobno: + +```python +import httpx + +from ksef2 import KSeFApiError, KSeFException, KSeFRateLimitError + +try: + result = auth.invoices.query_metadata(filters=filters) +except KSeFRateLimitError as exc: + retry_after = exc.retry_after +except KSeFApiError as exc: + status_code = exc.status_code + exception_code = exc.exception_code +except KSeFException as exc: + context = exc.context +except httpx.HTTPError as exc: + transport_error = exc +``` + +Wyjątki timeoutów pollingu oznaczają wygaśnięcie lokalnego deadline'u. Same w +sobie nie dowodzą, że zdalna operacja KSeF nie powiodła się. Zapisuj referencje +przed czekaniem, żeby inny proces mógł wznowić polling. + +## 5. Sprawdź integracje low-level + +Trzymaj kod low-level w obsługiwanej powierzchni raw: + +```python +from ksef2.raw import spec, supp +from ksef2.raw.mappers import auth as auth_mapper +``` + +Ścieżka importu `ksef2.raw` jest stabilna, ale pola modeli podążają za +sprawdzoną wersją OpenAPI KSeF. Gdy Ministerstwo Finansów zmieni schemę, +kształty modeli raw mogą zmienić się w minor release SDK. + +## 6. Zaktualizuj użycie buildera FA(3) + +Nowy kod budowania faktur opieraj o publiczną fasadę FA(3): + +```python +from ksef2.fa3 import FA3InvoiceBuilder, KsefInvoiceDraft, VatRate +``` + +Gdy użytkownicy mogą przerwać edycję faktury, zapisuj wygenerowany XML albo stan +`KsefInvoiceDraft`. Nie zapisuj prywatnych atrybutów buildera. Założenia +biznesowe weryfikuj z oficjalnymi regułami FA(3) i wymaganiami księgowymi +własnej organizacji. + +## 7. Zamień stare linki dokumentacji + +Publiczna dokumentacja nie używa już starych tras `intro`, `workflows` ani +`api-signatures`. + +| Stara trasa | Aktualna trasa | +| --- | --- | +| `/ksef2/pl/sdk/intro/` | `/ksef2/pl/sdk/getting-started/overview/` | +| `/ksef2/pl/sdk/workflows/overview/` | `/ksef2/pl/sdk/how-to-guides/overview/` dla zadań albo `/ksef2/pl/sdk/concepts/overview/` dla pojęć | +| `/ksef2/sdk/reference/api-signatures/` | `/ksef2/sdk/reference/api/` | +| `/ksef2/pl/sdk/raw/overview/` | `/ksef2/pl/sdk/reference/low-level/overview/` | + +## Checklista końcowa + + + +1. Wyszukaj w kodzie aplikacji `ksef2.core`, `ksef2.infra` i + `ksef2.endpoints`. + + Zastąp je udokumentowanymi importami publicznymi albo wysokopoziomowymi + gałęziami klienta. + +2. Uruchom testy uwierzytelniania, wysyłki, zapytań, pobierania i administracji + w TEST albo DEMO. + + Zwróć szczególną uwagę na credentiale, kontekst podatnika, uprawnienia i + referencje zapisywane przed pollingiem. + +3. Przejrzyj logowanie. + + Nie loguj tokenów, kluczy prywatnych, haseł, surowego XML faktury ani + zserializowanego stanu sesji, chyba że polityka retencji jawnie na to + pozwala. + +4. Sprawdź oficjalną dokumentację KSeF dla zachowania, którego właścicielem jest + Ministerstwo Finansów. + + SDK opakowuje API; nie jest autorytetem dla prawnego ani operacyjnego + zachowania KSeF. + + + +## Powiązane strony + + + + diff --git a/docs/pl/workflows/overview.mdx b/docs/pl/how-to-guides/overview.mdx similarity index 64% rename from docs/pl/workflows/overview.mdx rename to docs/pl/how-to-guides/overview.mdx index df239c6..6d77195 100644 --- a/docs/pl/workflows/overview.mdx +++ b/docs/pl/how-to-guides/overview.mdx @@ -1,88 +1,115 @@ --- -title: Przegląd przepływów -description: Wybierz właściwy przepływ ksef2 dla publicznej powierzchni SDK. +title: Przegląd poradników praktycznych +description: Wybierz właściwy poradnik zadaniowy dla publicznej powierzchni SDK. --- import { Aside, CardGrid, LinkCard, Steps } from '@astrojs/starlight/components'; Ta sekcja jest dla sytuacji, w których znasz już podstawowy kształt SDK i chcesz wykonać konkretne zadanie w KSeF. Quickstart pokazuje jedną ścieżkę od -początku do końca; tutaj rozbijamy typowe decyzje produkcyjne. +początku do końca; te poradniki rozbijają typowe decyzje produkcyjne. + +## Konfiguracja + + + +## Praca z fakturami + + + + - + + +## Administracja + + + + +## Narzędzia pomocnicze + + @@ -127,9 +154,25 @@ początku do końca; tutaj rozbijamy typowe decyzje produkcyjne. -## Powiązane strony - -- [Quickstart](../getting-started/quickstart.md) -- [Konfiguracja klienta](client-setup.mdx) -- [Low-level API](../raw/overview.md) -- [Referencja API](../reference/api-signatures.md) +## Referencja + + + + + diff --git a/docs/pl/how-to-guides/profiles.mdx b/docs/pl/how-to-guides/profiles.mdx new file mode 100644 index 0000000..42b92ec --- /dev/null +++ b/docs/pl/how-to-guides/profiles.mdx @@ -0,0 +1,276 @@ +--- +title: Użyj profili +description: Współdziel lokalną konfigurację profili ksef2-cli z uwierzytelnianiem SDK. +--- + +import { Aside, LinkCard, Steps } from '@astrojs/starlight/components'; + +Profile to lokalne, nazwane ustawienia uwierzytelniania współdzielone przez +`ksef2-cli` i SDK. Używaj ich wtedy, gdy ta sama stacja developerska wykonuje +komendy w terminalu i skrypty Pythona dla tego samego kontekstu KSeF. + +Profil przechowuje ustawienia niesekretne: środowisko, NIP, metodę +uwierzytelnienia, ścieżki certyfikatów, ustawienia pollingu i nazwę zmiennej +środowiskowej z sekretem. Nie powinien przechowywać wartości tokenów KSeF, +haseł kluczy prywatnych ani haseł PKCS#12. + + + +## Utwórz profil w CLI + +CLI jest najwygodniejszą drogą do tworzenia i sprawdzania pliku profili. + +```bash +ksef2 profile create test-company \ + --env test \ + --nip 5261040828 \ + --test-cert + +ksef2 profile current +ksef2 profile show test-company +``` + +Profile tokenowe i certyfikatowe zapisują nazwę zmiennej środowiskowej, która +zawiera sekret, a nie samą wartość sekretu: + +```bash +export KSEF2_TOKEN=podmien-na-prawdziwy-token-ksef + +ksef2 profile create prod-token \ + --env production \ + --nip 5261040828 \ + --token-env KSEF2_TOKEN +``` + + + + + +## Uwierzytelnij się profilem + +`with_profile()` czyta ten sam plik profili co CLI. Wywołanie bez nazwy używa +wybranego profilu. + +```python +from ksef2 import Client, Environment + +with Client(Environment.TEST) as client: + auth = client.authentication.with_profile() +``` + +Przekaż nazwę, żeby pominąć aktywny profil dla jednego wywołania: + +```python +from ksef2 import Client, Environment + +with Client(Environment.TEST) as client: + auth = client.authentication.with_profile("test-company") +``` + +Środowisko klienta głównego musi zgadzać się ze środowiskiem wybranego profilu. +Profil `test` musi być użyty z `Client(Environment.TEST)`, profil `demo` z +`Client(Environment.DEMO)`, a profil `production` z +`Client(Environment.PRODUCTION)`. + +Jeżeli profil ma wybrać środowisko klienta, wczytaj go najpierw: + +```python +from ksef2 import Client +from ksef2.profiles import load_cli_profile + +profile_name, profile = load_cli_profile("prod-token") + +with Client(profile.sdk_environment) as client: + auth = client.authentication.with_profile(profile_name) +``` + +Klient async używa tego samego pliku profili i tej samej kolejności wyboru: + +```python +from ksef2 import AsyncClient, Environment + +async with AsyncClient(Environment.TEST) as client: + auth = await client.authentication.with_profile("test-company") +``` + +## Kolejność wyboru profilu + +SDK wybiera profil w tej samej kolejności co `ksef2-cli`: + +1. Jawna nazwa przekazana do `with_profile("name")`. +2. `KSEF2_PROFILE` w środowisku procesu. +3. `active_profile` w lokalnym pliku konfiguracji profili. + +Domyślny plik profili to: + +```text +~/.config/ksef2-cli/config.toml +``` + +Ustaw `KSEF2_CONFIG` albo przekaż `config_path`, gdy plik profili znajduje się +w innym miejscu: + +```python +from ksef2 import Client, Environment + +with Client(Environment.PRODUCTION) as client: + auth = client.authentication.with_profile( + "prod-token", + config_path="./local.ksef2.toml", + ) +``` + +## Zarządzaj profilami z kodu SDK + +Użyj `ProfileStore`, gdy narzędzie w Pythonie ma tworzyć, aktualizować, +wybierać albo sprawdzać ten sam plik profili co CLI. Użyj `with_profile()`, +gdy potrzebujesz tylko uwierzytelnienia. + +```python +from ksef2 import Environment +from ksef2.profiles import Profile, ProfileStore, TokenProfileAuth + +store = ProfileStore.default() +store.save( + "prod-token", + Profile( + environment=Environment.PRODUCTION, + nip="5261040828", + auth=TokenProfileAuth( + token_env="KSEF2_TOKEN", + context_type="nip", + ), + poll_interval=2.0, + max_poll_attempts=90, + ), + activate=True, + overwrite=True, +) +``` + +Dla profilu z certyfikatem TEST: + +```python +from ksef2 import Environment +from ksef2.profiles import Profile, ProfileStore, TestCertificateProfileAuth + +store = ProfileStore.default() +store.save( + "test-company", + Profile( + environment=Environment.TEST, + nip="5261040828", + auth=TestCertificateProfileAuth(), + ), + activate=True, + overwrite=True, +) +``` + +Sprawdzaj i wybieraj profile przez store: + +```python +from ksef2.profiles import ProfileStore + +store = ProfileStore.default() + +profiles = store.list() +current = store.current() +profile = store.get("prod-token") +store.use("prod-token") +store.delete("old-profile") +``` + +`current` to `None` albo krotka `(name, profile)`. + +## Co zawiera konfiguracja + +Wyrenderowany plik używa tych samych nazw pól co publiczne modele profili SDK: + +```toml +active_profile = "prod-token" + +[profiles.prod-token] +environment = "production" +nip = "5261040828" +poll_interval = 2.0 +max_poll_attempts = 90 + +[profiles.prod-token.auth] +type = "token" +token_env = "KSEF2_TOKEN" +context_type = "nip" +``` + +Profile XAdES zapisują ścieżki i nazwy zmiennych środowiskowych z hasłami: + +```python +from ksef2 import Environment +from ksef2.profiles import Profile, ProfileStore, XadesP12ProfileAuth + +store = ProfileStore.default() +store.save( + "prod-p12", + Profile( + environment=Environment.PRODUCTION, + nip="5261040828", + auth=XadesP12ProfileAuth( + p12="signing-credentials.p12", + p12_password_env="KSEF2_P12_PASSWORD", + ), + ), + activate=False, + overwrite=True, +) +``` + +## Zalecany przepływ + + + +1. Umieść wartości sekretów w zmiennych środowiskowych. + + Przykłady: `KSEF2_TOKEN`, `KSEF2_KEY_PASSWORD` i `KSEF2_P12_PASSWORD`. + +2. Utwórz albo wybierz profil przez `ksef2-cli`. + + Dzięki temu workflow w terminalu i skrypty Pythona używają tych samych + ustawień. + +3. Utwórz klienta SDK dla tego samego środowiska co profil. + + SDK sprawdza to przed uwierzytelnieniem. + +4. Wywołaj `client.authentication.with_profile()`. + + Zwrócony klient uwierzytelniony jest używany tak samo jak przy innych + metodach uwierzytelnienia SDK. + + + +## Powiązane strony + + + + diff --git a/docs/pl/how-to-guides/query-invoices.mdx b/docs/pl/how-to-guides/query-invoices.mdx new file mode 100644 index 0000000..c5ac9f7 --- /dev/null +++ b/docs/pl/how-to-guides/query-invoices.mdx @@ -0,0 +1,207 @@ +--- +title: Wyszukaj faktury +description: Wyszukuj metadane faktur przez filtry, paginację i polling ksef2. +--- + +import { Aside, LinkCard, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; + +Użyj `auth.invoices`, gdy potrzebujesz metadanych faktur poza sesją wysyłki. +Przykłady poniżej zakładają, że masz już uwierzytelnionego klienta `auth`. + +## Zbuduj wąski filtr + +Zacznij od pytania biznesowego: jaka rola, jakie pole daty i jaki przedział +czasu mają zostać przeszukane przez KSeF? + +```python +from datetime import datetime, timedelta, timezone + +from ksef2.models import InvoicesFilter + +now = datetime.now(tz=timezone.utc) + +filters = InvoicesFilter.for_seller( + date_from=now - timedelta(days=7), + date_to=now, + invoice_types=["vat"], + invoicing_mode="online", +) +``` + + + +## Pobierz jedną stronę + +Użyj `query_metadata()`, gdy potrzebujesz jednej strony dla ekranu, uzgodnienia +albo diagnostyki. + +```python +from ksef2.models import InvoiceMetadataParams + +page = auth.invoices.query_metadata( + filters=filters, + params=InvoiceMetadataParams(page_size=25, sort_order="asc"), +) + +# QueryInvoicesMetadataResponse +# { +# "has_more": false, +# "is_truncated": false, +# "permanent_storage_hwm_date": null, +# "invoices": [ +# { +# "ksef_number": "1234567890-20260625-...", +# "invoice_number": "FV/42/2026", +# "issue_date": "2026-06-25", +# "gross_amount": 1230.0, +# "currency": "PLN", +# "invoice_type": "vat" +# } +# ] +# } + +for invoice in page.invoices: + print(invoice.ksef_number, invoice.invoice_number) +``` + +`has_more` oznacza, że istnieje kolejna strona. `is_truncated` oznacza, że wynik +dotarł do granicy okna KSeF i powinien być kontynuowany od zwróconej daty cyklu +życia, a nie od zwykłego offsetu strony. + +## Przejdź po wynikach + +Użyj iteratora stron, gdy każda strona ma znaczenie. Użyj iteratora faktur, gdy +zadanie potrzebuje tylko wierszy faktur. + + + + +```python +params = InvoiceMetadataParams(page_size=100, sort_order="asc") + +for page in auth.invoices.query_metadata_pages(filters=filters, params=params): + print(f"page={len(page.invoices)} has_more={page.has_more}") + + for invoice in page.invoices: + print(invoice.ksef_number, invoice.permanent_storage_date) +``` + + + + +```python +params = InvoiceMetadataParams(page_size=100, sort_order="asc") + +for invoice in auth.invoices.all_metadata(filters=filters, params=params): + print(invoice.ksef_number, invoice.invoice_number) +``` + + + + +## Znajdź konkretną fakturę + +Jeśli znasz własny numer faktury, dodaj go do filtra. Jeśli masz już numer KSeF, +preferuj filtrowanie po `ksef_number` albo bezpośrednie pobranie faktury. + +```python +filters = InvoicesFilter.for_seller( + date_from=now - timedelta(days=30), + date_to=now, + invoice_number="FV/42/2026", +) + +page = auth.invoices.query_metadata(filters=filters) +``` + +```python +filters = InvoicesFilter.for_seller( + date_type="permanent_storage", + date_from=now - timedelta(days=30), + date_to=now, + ksef_number="1234567890-20260625-...", +) + +page = auth.invoices.query_metadata(filters=filters) +``` + +## Poczekaj po wysyłce + +Przetwarzanie w KSeF jest asynchroniczne. Jeśli przepływ wysyła fakturę i od +razu potrzebuje jej widoczności w API pobierania, polluj wąskim filtrem. + +```python +filters = InvoicesFilter.for_seller( + date_from=now - timedelta(days=1), + date_to=now, + invoice_number="FV/42/2026", +) + +result = auth.invoices.wait_for_invoices( + filters=filters, + timeout=120.0, + poll_interval=2.0, +) + +# QueryInvoicesMetadataResponse +# { +# "has_more": false, +# "is_truncated": false, +# "permanent_storage_hwm_date": null, +# "invoices": [ +# { +# "ksef_number": "1234567890-20260625-...", +# "invoice_number": "FV/42/2026" +# } +# ] +# } +``` + + + +## Zalecany przepływ + + + +1. Wybierz rolę podmiotu, jako który wyszukujesz. + +2. Wybierz pole daty pasujące do zadania: widok księgowy, widok przetwarzania + albo synchronizacja przyrostowa. + +3. Zbuduj wąski `InvoicesFilter`. + +4. Pobierz jedną stronę dla pracy interaktywnej albo użyj iteratorów dla zadań w + tle. + +5. Zapisz wartości `ksef_number` do późniejszego bezpośredniego pobierania albo + uzgodnienia eksportów. + + + +## Następne przepływy + + + + + + diff --git a/docs/pl/how-to-guides/query-peppol-providers.mdx b/docs/pl/how-to-guides/query-peppol-providers.mdx new file mode 100644 index 0000000..7a2e66d --- /dev/null +++ b/docs/pl/how-to-guides/query-peppol-providers.mdx @@ -0,0 +1,86 @@ +--- +title: Wyszukaj dostawców PEPPOL +description: Wyszukuj dostawców usług PEPPOL zarejestrowanych w KSeF. +--- + +import { LinkCard, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; + +Użyj `client.peppol`, gdy potrzebujesz publicznej listy dostawców usług PEPPOL +zarejestrowanych w KSeF. To gałąź klienta głównego i nie wymaga +uwierzytelniania. + +## Wyszukaj dostawców + + + + +```python +page = client.peppol.query() + +# ListPeppolProvidersResponse +# { +# "has_more": false, +# "providers": [ +# { +# "id": "PPL123456", +# "name": "Example PEPPOL Provider", +# "date_created": "2026-06-25T10:00:00Z" +# } +# ] +# } + +for provider in page.providers: + print(provider.id, provider.name) +``` + + + + +```python +for provider in client.peppol.all(): + print(provider.id, provider.name, provider.date_created) +``` + + + + +## Cache'uj wybory dostawców + +Rekordy dostawców są danymi referencyjnymi. Odpytaj je z KSeF, a potem cache'uj +id i nazwę wyświetlaną do wyboru użytkownika albo walidacji w produkcie. + +```python +providers_by_id = { + provider.id: provider.name + for provider in client.peppol.all() +} +``` + +## Zalecany przepływ + + + +1. Wyszukaj dostawców z klienta głównego. + +2. Zapisz id i nazwy dostawców w cache'u danych referencyjnych aplikacji. + +3. Używaj id dostawców przy walidacji wyborów użytkownika albo danych + związanych z PEPPOL. + +4. Odświeżaj cache zgodnie z wymaganiami świeżości danych w produkcie. + + + +## Następne przepływy + + + + diff --git a/docs/pl/how-to-guides/send-invoices.mdx b/docs/pl/how-to-guides/send-invoices.mdx new file mode 100644 index 0000000..fd526f2 --- /dev/null +++ b/docs/pl/how-to-guides/send-invoices.mdx @@ -0,0 +1,270 @@ +--- +title: Wyślij faktury +description: Wyślij XML FA(3) przez sesje online albo sesje wsadowe ksef2. +--- + +import { Aside, LinkCard, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; + +Użyj `auth.online_session(...)` do interaktywnej wysyłki i `auth.batch` dla +większych zestawów plików. Przykłady poniżej zakładają, że masz już +uwierzytelnionego klienta `auth`. + +## Zacznij od bajtów XML + +API wysyłki przyjmują bajty XML faktury. Te bajty mogą pochodzić z ERP, pliku +albo buildera FA(3) z SDK. + + + + +```python +from pathlib import Path + +invoice_xml = Path("invoice.xml").read_bytes() +``` + + + + +```python +from datetime import date +from decimal import Decimal + +from ksef2.fa3 import FA3InvoiceBuilder, VatRate + +invoice_xml = ( + FA3InvoiceBuilder() + .header(system_info="ksef2 send guide") + .seller( + name="Demo Seller Sp. z o.o.", + tax_id="5261040828", + country_code="PL", + address_line_1="Prosta 1", + address_line_2="00-001 Warszawa", + ) + .buyer( + name="Demo Buyer Sp. z o.o.", + country_code="PL", + address_line_1="Kwiatowa 2", + address_line_2="00-002 Warszawa", + ) + .standard() + .issue_date(date.today()) + .issue_place("Warszawa") + .invoice_number("FV/42/2026") + .rows() + .add_line( + name="Usługa konsultingowa", + quantity=Decimal("1"), + unit_price_net=Decimal("100.00"), + vat_rate=VatRate.VAT_23, + ) + .done() + .done() + .to_xml() + .encode("utf-8") +) +``` + + + + +## Wyślij w sesji online + +Sesji online użyj, gdy wysyłasz jedną fakturę albo mały interaktywny zestaw. +Context manager zamyka zdalną sesję online po wyjściu z bloku. + +```python +from ksef2 import FormSchema + +with auth.online_session(form_code=FormSchema.FA3) as session: + sent = session.send_invoice(invoice_xml=invoice_xml) + + # SendInvoiceResponse + # { + # "reference_number": "20260625-ABCD-EF1234567890" + # } + + status = session.wait_for_invoice_ready( + invoice_reference_number=sent.reference_number, + timeout=120.0, + poll_interval=2.0, + ) + + # SessionInvoiceStatusResponse + # { + # "reference_number": "20260625-ABCD-EF1234567890", + # "ksef_number": "1234567890-20260625-...", + # "invoice_number": "FV/42/2026", + # "status": { + # "code": 200, + # "description": "Processed" + # } + # } +``` + +`send_invoice()` oznacza tylko, że KSeF przyjął zaszyfrowany payload do sesji. +`wait_for_invoice_ready()` czeka na wynik konkretnej faktury i zwraca numer KSeF +po udanym przetworzeniu. + + + +## Zachowaj uchwyty sesji + +Zapisz uchwyty przed długim pollingiem albo przed przekazaniem pracy innemu +procesowi. + +```python +with auth.online_session(form_code=FormSchema.FA3) as session: + session_state_json = session.resume_state().to_json() + sent = session.send_invoice(invoice_xml=invoice_xml) + invoice_reference_number = sent.reference_number + +# Zapisz session_state_json i invoice_reference_number w bezpiecznym magazynie. +# Nie loguj session_state_json, bo zawiera dane szyfrowania sesji. +``` + +## Wyślij batch + +Batcha użyj, gdy chcesz wysłać wiele plików XML jako jeden przepływ KSeF. +Serwis wysokiego poziomu przygotowuje paczkę ZIP, szyfruje części, otwiera +sesję batch, wysyła części, zamyka sesję i zwraca `BatchSessionResumeState`. + + + + +```python +from pathlib import Path + +from ksef2 import FormSchema + +prepared = auth.batch.prepare_batch_from_paths( + invoice_paths=[ + Path("invoice-1.xml"), + Path("invoice-2.xml"), + ], + form_code=FormSchema.FA3, +) + +state = auth.batch.submit_prepared_batch(prepared_batch=prepared) + +# BatchSessionResumeState(reference_number="20260625-BATCH-...") +# Zapisz state bezpiecznie. Zawiera dane szyfrowania i URL-e uploadu. +``` + + + + +```python +from pathlib import Path + +from ksef2 import FormSchema +from ksef2.models import BatchInvoice + +state = auth.batch.submit_batch( + invoices=[ + BatchInvoice( + file_name="invoice-1.xml", + content=Path("invoice-1.xml").read_bytes(), + ), + BatchInvoice( + file_name="invoice-2.xml", + content=Path("invoice-2.xml").read_bytes(), + ), + ], + form_code=FormSchema.FA3, +) + +# BatchSessionResumeState(reference_number="20260625-BATCH-...") +# Zapisz state bezpiecznie. Zawiera dane szyfrowania i URL-e uploadu. +``` + + + + +## Poczekaj na zakończenie batcha + +Po wysłaniu batcha polluj sesję batch i sprawdź faktury przyjęte oraz odrzucone. + +```python +final_status = auth.batch.wait_for_completion( + session=state, + timeout=300.0, + poll_interval=2.0, +) + +# SessionStatusResponse +# { +# "status": { +# "code": 200, +# "description": "Processed" +# }, +# "invoice_count": 2, +# "successful_invoice_count": 2, +# "failed_invoice_count": 0, +# "upo": { +# "pages": [ +# { +# "reference_number": "upo-page-reference" +# } +# ] +# } +# } + +accepted = auth.batch.list_invoices(session=state, page_size=100) +failed = auth.batch.list_failed_invoices(session=state, page_size=100) +``` + + + +## Zalecany przepływ + + + +1. Uwierzytelnij się w kontekście sprzedawcy. + +2. Wczytaj XML faktury z własnego systemu albo zbuduj go przez + `FA3InvoiceBuilder`. + +3. Użyj sesji online dla małej interaktywnej wysyłki albo `auth.batch` dla + większych zestawów. + +4. Zapisz zwrócone referencje sesji i faktur przed pollingiem. + +5. Odpytuj status, zapisz wyniki przyjęte i odrzucone, a potem pobierz UPO albo + XML faktury z kolejnych stron. + + + +## Następne przepływy + + + + + + + + diff --git a/docs/pl/how-to-guides/use-test-data.mdx b/docs/pl/how-to-guides/use-test-data.mdx new file mode 100644 index 0000000..465c4ce --- /dev/null +++ b/docs/pl/how-to-guides/use-test-data.mdx @@ -0,0 +1,151 @@ +--- +title: Użyj danych TEST +description: Twórz sandboxowe podmioty, osoby, uprawnienia, flagi załączników i blokady kontekstów w TEST. +--- + +import { Aside, LinkCard, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; + +Używaj `client.testdata` tylko z `Environment.TEST`. Te helpery modyfikują dane +sandboxa KSeF, dzięki czemu testy i demo mogą tworzyć znane konteksty. + +## Utwórz dane sandboxa ręcznie + +Bezpośrednich metod używaj dla współdzielonych fixture'ów, które mają przetrwać +wiele uruchomień testów. + + + + +```python +client.testdata.create_subject( + nip="5261040828", + subject_type="vat_group", + description="Sandbox company", +) + +client.testdata.enable_attachments(nip="5261040828") +``` + + + + +```python +client.testdata.create_person( + nip="5261040828", + pesel="90010112345", + description="Sandbox person", +) +``` + + + + +```python +from ksef2.models import AuthContextIdentifier + +context = AuthContextIdentifier(type="nip", value="5261040828") + +client.testdata.block_context(context=context) +client.testdata.unblock_context(context=context) +``` + + + + +Usuń współdzielone fixture'y jawnie, gdy nie są już potrzebne: + +```python +client.testdata.delete_person(nip="5261040828") +client.testdata.delete_subject(nip="5261040828") +``` + +## Użyj temporal cleanup + +`temporal()` zapisuje mutacje i próbuje posprzątać je best-effort po wyjściu z +bloku. + + + + +```python +with client.testdata.temporal() as data: + data.create_subject( + nip="5261040828", + subject_type="vat_group", + description="Integration test subject", + ) + data.enable_attachments(nip="5261040828") +``` + + + + +```python +from ksef2.models import Identifier, Permission + +with client.testdata.temporal() as data: + data.grant_permissions( + permissions=[ + Permission(type="invoice_read", description="Read invoices"), + ], + grant_to=Identifier(type="nip", value="1111111111"), + in_context_of=Identifier(type="nip", value="5261040828"), + ) +``` + + + + +```python +from ksef2.models import AuthContextIdentifier + +context = AuthContextIdentifier(type="nip", value="5261040828") + +with client.testdata.temporal() as data: + data.block_context(context=context) +``` + + + + + + +## Zalecany przepływ + + + +1. Utwórz tylko podmioty, osoby, uprawnienia, flagi załączników albo blokady + kontekstów wymagane przez test. + +2. Użyj `temporal()` dla fixture'ów, które powinny zostać posprzątane + automatycznie. + +3. Użyj bezpośrednich metod create/delete dla fixture'ów współdzielonych przez + wiele uruchomień testów. + +4. Trzymaj wygenerowane identyfikatory w konfiguracji testów, nie produkcji. + + + +## Następne przepływy + + + + + + diff --git a/docs/pl/how-to-guides/use-xades-helpers.mdx b/docs/pl/how-to-guides/use-xades-helpers.mdx new file mode 100644 index 0000000..85f2321 --- /dev/null +++ b/docs/pl/how-to-guides/use-xades-helpers.mdx @@ -0,0 +1,148 @@ +--- +title: Użyj pomocników XAdES +description: Ładuj certyfikaty i klucze prywatne, generuj certyfikaty TEST i podpisuj XML dla uwierzytelniania KSeF. +--- + +import { Aside, LinkCard, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; + +Użyj pomocników XAdES, gdy uwierzytelniasz się materiałem certyfikatu albo +diagnozujesz podpisany XML uwierzytelniania. Większość aplikacji powinna nadal +wywoływać `client.authentication.with_xades()` zamiast podpisywać ręcznie. + +## Załaduj materiał certyfikatu + + + + +```python +import os + +from ksef2.xades import load_certificate_from_pem, load_private_key_from_pem + +password = os.environ.get("KSEF2_KEY_PASSWORD") + +cert = load_certificate_from_pem("company.pem") +private_key = load_private_key_from_pem( + "company.key", + password=password.encode() if password else None, +) +``` + + + + +```python +import os + +from ksef2.xades import load_certificate_and_key_from_p12 + +password = os.environ.get("KSEF2_P12_PASSWORD") + +cert, private_key = load_certificate_and_key_from_p12( + "company.p12", + password=password.encode() if password else None, +) +``` + + + + +## Uwierzytelnij przez XAdES + +Przekaż załadowany certyfikat i klucz prywatny do gałęzi uwierzytelniania. + +```python +auth = client.authentication.with_xades( + nip="5261040828", + cert=cert, + private_key=private_key, +) +``` + +## Wygeneruj materiał certyfikatu TEST + +Wygenerowanych certyfikatów używaj tylko w przepływach TEST. + + + + +```python +from ksef2.xades import generate_test_certificate + +cert, private_key = generate_test_certificate(nip="5261040828") +``` + + + + +```python +from ksef2.xades import generate_personal_test_certificate + +cert, private_key = generate_personal_test_certificate( + pesel="90010112345", + nip="5261040828", +) +``` + + + + +## Podpisz XML bezpośrednio + +Bezpośrednich helperów podpisu używaj do diagnostyki albo niższych testów +integracyjnych. Dla zwykłego uwierzytelniania preferuj `with_xades()`. + +```python +from ksef2.xades import build_auth_token_request_xml, sign_xades + +xml = build_auth_token_request_xml( + challenge="challenge-from-ksef", + nip="5261040828", +) + +signed_xml = sign_xades(xml, cert, private_key) +``` + + + +## Zalecany przepływ + + + +1. Załaduj materiał certyfikatu z PEM albo PKCS#12. + +2. Trzymaj hasła kluczy prywatnych w zmiennych środowiskowych albo managerze + sekretów. + +3. Uwierzytelniaj się przez `with_xades()`, gdy to możliwe. + +4. Generuj samopodpisane certyfikaty tylko dla TEST. + +5. Używaj bezpośrednich helperów podpisu tylko do diagnostyki albo + niskopoziomowych testów integracyjnych. + + + +## Następne przepływy + + + + + + diff --git a/docs/pl/intro.md b/docs/pl/intro.md deleted file mode 100644 index 0b68f86..0000000 --- a/docs/pl/intro.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -title: Przegląd -description: Punkt startowy polskiej dokumentacji ksef2 SDK. ---- - -ksef2 to SDK Pythona dla API KSeF v2. Udostępnia klientów sync i async, -typowane modele żądań, pomocniki sesji faktur i low-level dostęp do endpointów. - -> **Nieoficjalne SDK.** ksef2 jest społecznościowo utrzymywanym SDK dla -> Pythona. Nie jest publikowane, zatwierdzane ani wspierane przez Ministerstwo -> Finansów. Oficjalna dokumentacja KSeF pozostaje źródłem prawdy dla zachowania -> API. - -## Start - -- [Quickstart](getting-started/quickstart.md) -- [Wybierz przepływ uwierzytelniania](workflows/authentication.mdx) -- [Wyślij, znajdź i pobierz faktury](workflows/overview.mdx) - -## Główne strony - -- [Przegląd przepływów](workflows/overview.mdx): ścieżki zadaniowe dla - klientów, uwierzytelniania, faktur, statusu, tokenów, uprawnień, - certyfikatów, limitów, publicznych lookupów, danych TEST i XAdES. -- [Konfiguracja klienta](workflows/client-setup.mdx): sync albo async, publiczne - gałęzie klienta głównego i gałęzie uwierzytelnione. -- [Kontrakt publicznego API](guides/public-api.md): stabilne ścieżki importu i - granice pakietów internal dla kodu aplikacyjnego. -- [Obsługa błędów](guides/errors.md): łapanie wyjątków SDK, diagnostyka - odpowiedzi KSeF i timeouty pollingu. -- [Wysyłanie faktur](workflows/sending-invoices.mdx): sesje online albo batch i - wysyłka XML do KSeF. -- [Przepływy administracyjne](workflows/tokens.mdx): zacznij od tokenów, potem - używaj uprawnień, certyfikatów i limitów według potrzeb. -- [Low-level API](raw/overview.md): używaj schema-native wrapperów endpointów do - własnego podpisywania, custody szyfrowania albo debugowania payloadów KSeF. - -## Referencja - -Użyj [referencji API](reference/api-signatures.md), gdy potrzebujesz sygnatur, -typów zwracanych, nazw modeli albo dokładnych wariantów sync/async. diff --git a/docs/pl/raw/authentication.md b/docs/pl/raw/authentication.md deleted file mode 100644 index e608741..0000000 --- a/docs/pl/raw/authentication.md +++ /dev/null @@ -1,107 +0,0 @@ ---- -title: Low-level uwierzytelnianie -description: Wykonuj kroki uwierzytelniania KSeF ręcznie i wiąż wynik z klientami ksef2. ---- - -Użyj low-level uwierzytelniania, gdy inny system odpowiada za podpis, -szyfrowanie tokenu albo politykę pollingu. SDK nadal wysyła żądania i parsuje -odpowiedzi, ale twój kod wybiera każdy krok. - -## Uwierzytelnianie tokenem - -```python -from time import sleep - -from ksef2 import Client, Environment -from ksef2.raw import encrypt_token, spec -from ksef2.raw.mappers import auth as auth_mapper - -POLL_INTERVAL_SECONDS = 1.0 -MAX_POLL_ATTEMPTS = 60 - -client = Client(Environment.TEST) - -challenge = client.raw.auth.challenge() -cert = next( - cert - for cert in client.raw.encryption.fetch_public_certificates() - if spec.PublicKeyCertificateUsage.KsefTokenEncryption in cert.usage -) - -encrypted = encrypt_token( - "your-ksef-token", - str(challenge.timestampMs), - cert.certificate, -) -request = spec.InitTokenAuthenticationRequest( - challenge=challenge.challenge, - contextIdentifier=spec.AuthenticationContextIdentifier( - type=spec.AuthenticationContextIdentifierType.Nip, - value="5261040828", - ), - encryptedToken=encrypted, - publicKeyId=cert.publicKeyId, -) - -init = client.raw.auth.token_auth(request) - -for _ in range(MAX_POLL_ATTEMPTS): - status = client.raw.auth.auth_status( - bearer_token=init.authenticationToken.token, - reference_number=init.referenceNumber, - ) - - if status.status.code == 200: - break - if status.status.code >= 400: - raise RuntimeError( - f"Authentication failed: {status.status.code} " - f"{status.status.description}" - ) - - sleep(POLL_INTERVAL_SECONDS) -else: - raise TimeoutError("Authentication did not finish") - -raw_tokens = client.raw.auth.redeem_token(init.authenticationToken.token) -auth = client.authenticated(auth_mapper.from_spec(raw_tokens)) -``` - -Po `client.authenticated(...)` możesz używać gałęzi workflow, takich jak -`auth.invoices`, albo gałęzi low-level, takich jak `auth.raw.invoices`. - -## XAdES z zewnętrznym podpisywaniem - -Ten kształt pasuje do gatewaya podpisu albo HSM, który zwraca podpisany XML. - -```python -from ksef2.raw.mappers import auth as auth_mapper - -challenge = client.raw.auth.challenge() -signed_xml = signing_gateway.sign_ksef_challenge( - challenge=challenge.challenge, - nip="5261040828", -) - -init = client.raw.auth.xades_auth(signed_xml, verify_chain=True) -status = client.raw.auth.auth_status( - bearer_token=init.authenticationToken.token, - reference_number=init.referenceNumber, -) - -if status.status.code == 200: - raw_tokens = client.raw.auth.redeem_token(init.authenticationToken.token) - auth = client.authenticated(auth_mapper.from_spec(raw_tokens)) -``` - -## Async - -Klienci async mają te same gałęzie low-level. Wywołania sieciowe wymagają -`await`: - -```python -challenge = await client.raw.auth.challenge() -init = await client.raw.auth.xades_auth(signed_xml) -raw_tokens = await client.raw.auth.redeem_token(init.authenticationToken.token) -auth = client.authenticated(auth_mapper.from_spec(raw_tokens)) -``` diff --git a/docs/pl/raw/endpoint-map.md b/docs/pl/raw/endpoint-map.md deleted file mode 100644 index 6a930e4..0000000 --- a/docs/pl/raw/endpoint-map.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -title: Mapa endpointów low-level -description: Znajdź grupę endpointów low-level ksef2 dla wybranego obszaru KSeF. ---- - -Grupy low-level są cienkimi fasadami nad wrapperami endpointów SDK. Są dostępne -przez `client.raw` i `auth.raw`, używają schema-native modeli żądań i odpowiedzi -oraz tego samego transportu co klienci workflow. - -## Przed uwierzytelnieniem - -| Gałąź low-level | Zastosowanie | -| --- | --- | -| `client.raw.auth` | Challenge, token auth, XAdES auth, status auth i redeem tokena. | -| `client.raw.encryption` | Publiczne certyfikaty szyfrowania KSeF. | -| `client.raw.peppol` | Publiczny lookup dostawców PEPPOL. | -| `client.raw.testdata` | Endpointy TEST dla podmiotów, osób, uprawnień, załączników i kontekstu. | - -## Po uwierzytelnieniu - -| Gałąź low-level | Zastosowanie | -| --- | --- | -| `auth.raw.auth` | Listowanie i zamykanie sesji auth. | -| `auth.raw.certificates` | Limity, rejestracja, pobieranie, wyszukiwanie i cofanie certyfikatów. | -| `auth.raw.encryption` | Publiczne certyfikaty szyfrowania KSeF. | -| `auth.raw.invoices` | Metadane, eksport, pobieranie, wysyłka online, status faktur sesji i UPO. | -| `auth.raw.limits` | Limity kontekstu, podmiotu i API. | -| `auth.raw.peppol` | Publiczny lookup dostawców PEPPOL. | -| `auth.raw.permissions.grant` | Endpointy nadawania uprawnień. | -| `auth.raw.permissions.revoke` | Endpointy cofania uprawnień. | -| `auth.raw.permissions.query` | Wyszukiwanie uprawnień i status załączników. | -| `auth.raw.permissions.status` | Status operacji uprawnień i role podmiotu. | -| `auth.raw.session` | Otwieranie/zamykanie sesji online i batch, UPO sesji, listowanie sesji. | -| `auth.raw.testdata` | Endpointy fixture'ów TEST. | -| `auth.raw.tokens` | Generowanie, listowanie, status i cofanie tokenów. | - -## Importy - -```python -from ksef2.raw import ( - encrypt_invoice, - encrypt_symmetric_key, - encrypt_token, - generate_session_key, - prepare_batch_package, - sha256_b64, - spec, - supp, -) -from ksef2.raw.mappers import auth as auth_mapper -``` - -Eksportowane utility low-level pozostają ograniczone do mechaniki KSeF: -szyfrowania i hashy. Body żądań pozostają jawne przez modele `spec.*`. -Publiczne mappery, takie jak `auth_mapper.from_spec(...)`, są jawnym mostem z -modeli odpowiedzi low-level do modeli domenowych SDK. diff --git a/docs/pl/raw/overview.md b/docs/pl/raw/overview.md deleted file mode 100644 index fa73a90..0000000 --- a/docs/pl/raw/overview.md +++ /dev/null @@ -1,75 +0,0 @@ ---- -title: Low-level API -description: Używaj schema-native wywołań endpointów, gdy integracja KSeF wymaga niższego poziomu kontroli. ---- - -Low-level API to zaawansowana warstwa SDK dla kodu, który potrzebuje kontroli -na poziomie endpointów bez wychodzenia poza transport SDK. W kodzie jest -dostępna przez gałąź `raw`. Użyj jej przy własnym podpisywaniu, custody kluczy, -dokładnych payloadach OpenAPI albo debugowaniu tego, co trafia do KSeF. - -Większość kodu aplikacyjnego powinna nadal zaczynać od klientów workflow. -Low-level API jest jawnie niższym poziomem: żądania i odpowiedzi używają nazw -pól KSeF/OpenAPI, takich jak `referenceNumber`, `publicKeyId` i -`authenticationToken`. - -## Trzy poziomy - -```python -# Workflow: SDK wykonuje całe zadanie. -status = session.send_invoice_and_wait(invoice_xml=invoice_xml) - -# Step level: SDK zna szczegóły protokołu, caller decyduje o kolejności. -result = session.send_invoice(invoice_xml=invoice_xml) -status = session.wait_for_invoice_ready( - invoice_reference_number=result.reference_number, -) - -# Low-level API: caller decyduje o endpointach i schema-native payloadach. -sent = auth.raw.invoices.send(reference_number, send_request) -``` - -Low-level API nadal jest wyżej niż `httpx`: zachowuje retry SDK, lifecycle -checks, bearer-token middleware, parsowanie odpowiedzi i mapowanie wyjątków -KSeF. - -## Import modeli - -Importuj modele schema-native z `ksef2.raw`, nie z wewnętrznego pakietu `infra`. - -```python -from ksef2.raw import spec - -request = spec.GenerateTokenRequest(...) -response = auth.raw.tokens.generate_token(request) -``` - -Część metod low-level używa supplemental schema models SDK, gdy wygenerowany -model OpenAPI nie jest przyjazny dla Pythona. Te modele są re-eksportowane z -`ksef2.raw.spec` dla typowej ścieżki, a `ksef2.raw.supp` jest dostępne, gdy -potrzebujesz bezpośredniego pakietu supplemental. - -## Mieszanie low-level i workflow - -Możesz przechodzić między poziomami. Typowy wzorzec to ręczne low-level -uwierzytelnianie, a potem normalne workflow: - -```python -from ksef2.raw.mappers import auth as auth_mapper - -raw_tokens = client.raw.auth.redeem_token(auth_token) -auth = client.authenticated(auth_mapper.from_spec(raw_tokens)) - -with auth.online_session(form_code=FormSchema.FA3) as session: - status = session.send_invoice_and_wait(invoice_xml=invoice_xml) -``` - -Główna zasada dotyczy właściciela sesji. Jeśli low-level API otwiera sesję, ten -sam poziom zwykle powinien też ją zamknąć i odpytywać. Jeśli sesję otwiera -wysoki poziom, używaj klienta sesji zwróconego przez SDK. - -## Sekcja Low-level API - -- [Ręczne uwierzytelnianie](authentication.md) -- [Sesje i faktury](sessions-invoices.md) -- [Mapa endpointów](endpoint-map.md) diff --git a/docs/pl/raw/sessions-invoices.md b/docs/pl/raw/sessions-invoices.md deleted file mode 100644 index 8a07080..0000000 --- a/docs/pl/raw/sessions-invoices.md +++ /dev/null @@ -1,109 +0,0 @@ ---- -title: Low-level sesje i faktury -description: Otwieraj sesje KSeF i wysyłaj faktury przez schema-native endpointy low-level. ---- - -Użyj low-level sesji i faktur, gdy integracja musi sama posiadać materiał -szyfrowania, otwieranie sesji, wysyłkę faktury albo kolejność pollingu. - -## Otwórz sesję online - -```python -import base64 - -from ksef2.raw import ( - encrypt_symmetric_key, - generate_session_key, - spec, -) - -cert = next( - cert - for cert in auth.raw.encryption.fetch_public_certificates() - if spec.PublicKeyCertificateUsage.SymmetricKeyEncryption in cert.usage -) -aes_key, iv = generate_session_key() -encrypted_key = encrypt_symmetric_key(aes_key, cert.certificate) - -opened = auth.raw.session.open_online( - spec.OpenOnlineSessionRequest( - formCode=spec.FormCode( - systemCode="FA (3)", - schemaVersion="1-0E", - value="FA", - ), - encryption=spec.EncryptionInfo( - encryptedSymmetricKey=base64.b64encode(encrypted_key).decode(), - initializationVector=base64.b64encode(iv).decode(), - publicKeyId=cert.publicKeyId, - ), - ) -) -``` - -Zachowaj `aes_key`, `iv` i `opened.referenceNumber`; low-level wysyłka faktury -potrzebuje wszystkich trzech wartości. - -## Wyślij jedną fakturę - -```python -import base64 - -from ksef2.raw import encrypt_invoice, sha256_b64, spec - -encrypted = encrypt_invoice(xml_bytes=invoice_xml, key=aes_key, iv=iv) -request = spec.SendInvoiceRequest( - invoiceHash=sha256_b64(invoice_xml), - invoiceSize=len(invoice_xml), - encryptedInvoiceHash=sha256_b64(encrypted), - encryptedInvoiceSize=len(encrypted), - encryptedInvoiceContent=base64.b64encode(encrypted).decode(), -) - -sent = auth.raw.invoices.send(opened.referenceNumber, request) -status = auth.raw.invoices.get_session_invoice_status( - opened.referenceNumber, - sent.referenceNumber, -) -``` - -Low-level API nie polluje za ciebie. Odpytuj `get_session_invoice_status()`, aż -odpowiedź zawiera numer KSeF albo końcowy status błędu. - -## Zaplanuj eksport z własnym szyfrowaniem - -```python -import base64 -from datetime import datetime, timezone - -from ksef2.raw import spec - -request = spec.InvoiceExportRequest( - encryption=spec.EncryptionInfo( - encryptedSymmetricKey=base64.b64encode(encrypted_key).decode(), - initializationVector=base64.b64encode(iv).decode(), - publicKeyId=cert.publicKeyId, - ), - filters=spec.InvoiceQueryFilters( - subjectType=spec.InvoiceQuerySubjectType.Subject1, - dateRange=spec.InvoiceQueryDateRange( - dateType=spec.InvoiceQueryDateType.Issue, - **{"from": datetime(2026, 1, 1, tzinfo=timezone.utc)}, - ), - ), - compressionType=spec.CompressionType.Zip, -) - -export = auth.raw.invoices.export(request) -package = auth.raw.invoices.get_export_status(export.referenceNumber) -``` - -Użyj wyższego poziomu `auth.invoices.fetch_package_bytes(...)` tylko wtedy, gdy -masz też `aes_key` i `iv` wymagane do odszyfrowania paczki. - -## Przygotowanie batcha - -`ksef2.raw.prepare_batch_package` re-eksportuje niższopoziomowy builder paczki -batch. Możesz przekazać `aes_key`, `iv`, `encrypted_key` i `public_key_id`, a -potem otworzyć sesję przez `auth.raw.session.open_batch()` albo step-level -`auth.open_batch_session(...)`. diff --git a/docs/pl/reference/client-lifecycle.mdx b/docs/pl/reference/client-lifecycle.mdx new file mode 100644 index 0000000..f8c62b6 --- /dev/null +++ b/docs/pl/reference/client-lifecycle.mdx @@ -0,0 +1,141 @@ +--- +title: Referencja cyklu życia klienta +description: Argumenty konstruktorów, własność gałęzi, zamykanie zasobów i błędy lifecycle w klientach ksef2. +--- + +import { LinkCard } from '@astrojs/starlight/components'; + +Ta strona opisuje kontrakt referencyjny klientów głównych i klientów +uwierzytelnionych. Użyj poradnika praktycznego, gdy potrzebujesz kompletnej +sekwencji setupu. + +## Konstruktory klientów głównych + +```python +Client( + environment=Environment.PRODUCTION, + *, + transport_config=None, + http_client=None, +) + +AsyncClient( + environment=Environment.PRODUCTION, + *, + transport_config=None, + http_client=None, +) +``` + +| Argument | Typ sync | Typ async | Domyślnie | Znaczenie | +| --- | --- | --- | --- | --- | +| `environment` | `Environment` | `Environment` | `Environment.PRODUCTION` | Środowisko KSeF i base URL klienta głównego. | +| `transport_config` | `TransportConfig | None` | `TransportConfig | None` | Timeouty HTTP, pool, TLS, proxy, HTTP/2 i retry dla klienta HTTP tworzonego przez SDK. | +| `http_client` | `httpx.Client | None` | `httpx.AsyncClient | None` | Klient HTTP przekazany przez aplikację. Gdy jest podany, aplikacja posiada jego ustawienia HTTP i końcowe zamknięcie. | + +Gdy `http_client` nie jest podany, SDK tworzy klienta `httpx` z +`transport_config` i zamyka go razem z klientem głównym. + +## Elementy klienta głównego + +| Element | Dostępny na | Zwraca | Uwagi | +| --- | --- | --- | --- | +| `authentication` | `Client`, `AsyncClient` | `AuthClient` / `AsyncAuthClient` | Punkt wejścia do tokenu, XAdES, certyfikatu TEST i profili. | +| `encryption` | `Client`, `AsyncClient` | `EncryptionClient` / `AsyncEncryptionClient` | Publiczny odczyt certyfikatów szyfrowania KSeF. | +| `peppol` | `Client`, `AsyncClient` | `PeppolClient` / `AsyncPeppolClient` | Publiczny lookup dostawców PEPPOL. | +| `testdata` | `Client`, `AsyncClient` | `TestDataClient` / `AsyncTestDataClient` | Gałąź fixture'ów tylko dla TEST. Rzuca błąd poza `Environment.TEST`. | +| `raw` | `Client`, `AsyncClient` | `RawClient` / `AsyncRawClient` | Low-level nieuwierzytelnione grupy endpointów. | +| `authenticated(auth_tokens)` | `Client`, `AsyncClient` | `AuthenticatedClient` / `AsyncAuthenticatedClient` | Deprecated compatibility wrapper. Użyj `authentication.resume(AuthenticationResumeState.from_tokens(auth_tokens))`. | +| `close()` | `Client` | `None` | Idempotentnie zamyka klienta HTTP sync należącego do SDK i unieważnia gałęzie w cache. | +| `aclose()` | `AsyncClient` | `None` | Idempotentnie zamyka zasoby async i unieważnia gałęzie w cache. | + +Właściwości gałęzi są cache'owane, dopóki klient główny jest otwarty. Po +zamknięciu klienta dostęp do gałęzi i operacje chronione lifecycle middleware +rzucają `KSeFClientClosedError`. + +## Context managery + +| Klient | Protokół context managera | Metoda cleanup | +| --- | --- | --- | +| `Client` | `with Client(...) as client:` | `client.close()` przy wyjściu. | +| `AsyncClient` | `async with AsyncClient(...) as client:` | `await client.aclose()` przy wyjściu. | + +Context manager zamyka tylko klienta głównego. Sesje online i batch są osobnymi +granicami cyklu życia i powinny być zamykane przez własne context managery albo +metody serwisów. + +## Elementy klienta uwierzytelnionego + +Uwierzytelnienie zwraca klienta uwierzytelnionego powiązanego z jednym +kontekstem KSeF i jedną parą tokenów. + +| Element | Zwraca | Uwagi | +| --- | --- | --- | +| `auth_tokens` | `AuthTokens` | Modele tokenów access i refresh używane przez tę gałąź. | +| `access_token` | `str` | String bearer access tokenu. Traktuj jako sekret. | +| `refresh_token` | `str` | String refresh tokenu. Traktuj jako sekret. | +| `resume_state()` | `AuthenticationResumeState` | Serializowalny stan uwierzytelnienia zawierający access i refresh token. | +| `online_session(form_code=...)` | Klient sesji online | Otwiera jedną sesję online faktur. Async zwraca awaitable wrapper context managera. | +| `resume_online_session(state)` | Klient sesji online | Wiąże zapisany `OnlineSessionResumeState` z bieżącym transportem uwierzytelnionym. | +| `batch_session(...)` | Klient sesji batch | Otwiera sesję batch z przygotowanego batcha albo deklaracji pliku batch. | +| `open_batch_session(...)` | Klient sesji batch | Otwiera sesję batch, gdy caller posiada metadane szyfrowania. | +| `resume_batch_session(state)` | Klient sesji batch | Wiąże zapisany `BatchSessionResumeState` z bieżącym transportem uwierzytelnionym. | +| `invoices` | `InvoicesService` | Metadane, pobrania bezpośrednie, eksporty, pobieranie paczek i wait helpery. | +| `batch` | `BatchService` | Wysokopoziomowy workflow przygotowania paczki, uploadu, zamknięcia, statusu i UPO. | +| `limits` | `LimitsClient` | Endpointy limitów kontekstu, podmiotu i API. | +| `tokens` | `TokensClient` | Generowanie, status, lista i unieważnianie tokenów. | +| `certificates` | `CertificatesClient` | Enrollment, pobranie, query, limity i unieważnianie certyfikatów. | +| `sessions` | `SessionManagementClient` | Lista i zamykanie sesji uwierzytelnienia. | +| `invoice_sessions` | `InvoiceSessionsClient` | Historia sesji online i batch faktur. | +| `permissions` | `PermissionsClient` | Nadawanie, query, cofanie i status operacji uprawnień. | +| `raw` | `RawAuthenticatedClient` | Low-level uwierzytelnione grupy endpointów. | + +`AuthenticationResumeState` jest właścicielem bearer access i refresh tokenów. +`OnlineSessionResumeState` i `BatchSessionResumeState` nie zawierają danych +uwierzytelnienia; wznawiaj je przez klienta uwierzytelnionego. + +Klienci uwierzytelnieni współdzielą transport i cache certyfikatów klienta +głównego. Nie posiadają osobnego klienta HTTP. + +## Własność klienta HTTP + +| Setup | Źródło konfiguracji HTTP | Kto zamyka zasoby HTTP | +| --- | --- | --- | +| `Client(Environment.TEST)` | SDK buduje `httpx.Client` z `TransportConfig`. | `Client.close()` albo wyjście z context managera. | +| `AsyncClient(Environment.TEST)` | SDK buduje `httpx.AsyncClient` z `TransportConfig`. | `await AsyncClient.aclose()` albo wyjście z async context managera. | +| `Client(..., http_client=http)` | Podany `httpx.Client`. | Aplikacja zamyka `http`. | +| `AsyncClient(..., http_client=http)` | Podany `httpx.AsyncClient`. | Aplikacja zamyka `http`. | + +Gdy `http_client` jest podany, ustawienia HTTP takie jak timeout, pool, TLS, +proxy, `trust_env`, HTTP/2, custom transporty i event hooki pochodzą z tego +obiektu. Middleware retry SDK nadal używa `transport_config.retry`. + +## Błędy lifecycle + +| Błąd | Kiedy jest rzucany | +| --- | --- | +| `KSeFClientClosedError` | Klient główny albo gałąź chroniona lifecycle jest użyta po zamknięciu. | +| `KSeFUnsupportedEnvironmentError` | Gałąź tylko dla TEST, obecnie `testdata`, jest użyta poza `Environment.TEST`. | + +## Powiązana referencja + + + + + diff --git a/docs/pl/reference/errors.mdx b/docs/pl/reference/errors.mdx new file mode 100644 index 0000000..98033df --- /dev/null +++ b/docs/pl/reference/errors.mdx @@ -0,0 +1,119 @@ +--- +title: Referencja błędów +description: Hierarchia wyjątków SDK, atrybuty, kody wyjątków KSeF i klasy timeoutów pollingu. +--- + +import { LinkCard } from '@astrojs/starlight/components'; + +ksef2 rzuca wyjątki SDK dla błędów, które potrafi sklasyfikować. Błędy +transportu, które wystąpią zanim KSeF zwróci sparsowaną odpowiedź, pozostają +wyjątkami `httpx.HTTPError`. + +## Klasy bazowe + +| Klasa | Baza | `code` | Główne atrybuty | +| --- | --- | --- | --- | +| `KSeFException` | `Exception` | `SDK_ERROR` | `context` | +| `KSeFApiError` | `KSeFException` | `API_ERROR` | `status_code`, `exception_code`, `response` | +| `KSeFAuthError` | `KSeFApiError` | `AUTH_ERROR` | `status_code`, `exception_code`, `response` | +| `KSeFRateLimitError` | `KSeFApiError` | `RATE_LIMIT_ERROR` | `retry_after`, `status_code`, `response` | + +Łap węższe podklasy przed `KSeFException`, gdy workflow ma konkretną akcję +odzyskiwania. + +```python +try: + result = auth.invoices.query_metadata(filters=filters) +except KSeFRateLimitError as exc: + retry_after = exc.retry_after +except KSeFApiError as exc: + status_code = exc.status_code + exception_code = exc.exception_code +except KSeFException as exc: + context = exc.context +except httpx.HTTPError as exc: + transport_error = exc +``` + +## Klasy wyjątków SDK + +| Klasa | `code` | Kiedy jest rzucana | +| --- | --- | --- | +| `KSeFClientClosedError` | `CLIENT_CLOSED` | Klient główny albo klient sesji użyty po zamknięciu. | +| `KSeFUnsupportedEnvironmentError` | `UNSUPPORTED_ENVIRONMENT` | Gałąź albo przepływ tylko dla TEST użyty poza `Environment.TEST`. | +| `KSeFValidationError` | `VALIDATION_ERROR` | Niepoprawne dane wejściowe SDK, błędny payload odpowiedzi, błędny profil albo błędne argumenty sesji/batch. | +| `KSeFInvoiceRenderingError` | `INVOICE_RENDERING_ERROR` | Błędy opcjonalnego renderowania XSLT/PDF. | +| `KSeFEncryptionError` | `ENCRYPTION_ERROR` | Błąd szyfrowania tokenu, klucza symetrycznego, faktury albo deszyfrowania. | +| `KSeFSessionError` | `SESSION_ERROR` | Naruszenie stanu sesji, na przykład użycie zamkniętej sesji. | +| `NoCertificateAvailableError` | `NO_CERTIFICATE_AVAILABLE` | Brak poprawnego certyfikatu dla podpisu albo szyfrowania. | +| `KSeFMetadataPaginationError` | `METADATA_PAGINATION_ERROR` | Paginacja metadanych nie może bezpiecznie kontynuować. | + +## Atrybuty błędu API + +`KSeFApiError` jest rzucany dla sparsowanych odpowiedzi KSeF 4xx i 5xx. +Specjalne podklasy są używane dla błędów auth/autoryzacji i rate limitu. + +| Atrybut | Typ | Znaczenie | +| --- | --- | --- | +| `status_code` | `int` | Status HTTP zwrócony przez KSeF. | +| `exception_code` | `ExceptionCode` | Znormalizowany kod wyjątku KSeF, gdy został rozpoznany. | +| `response` | `BaseModel | None` | Sparsowany payload błędu KSeF, gdy parsowanie się udało. | + +Używaj `response.model_dump()` albo `response.model_dump_json()` do +strukturalnych diagnostyk, gdy `response` nie jest `None`. + +## Wartości ExceptionCode + +| Nazwa | Wartość | +| --- | --- | +| `UNKNOWN_ERROR` | `10000` | +| `OBJECT_ALREADY_EXISTS` | `30001` | +| `VALIDATION_ERROR` | `21405` | +| `UPO_NOT_FOUND` | `21178` | +| `NOT_PROCESSED_YET` | `21165` | + +Nieznane numeryczne kody KSeF mapują się do `ExceptionCode.UNKNOWN_ERROR`. + +## Klasy timeoutów pollingu + +Wyjątki timeoutów pollingu oznaczają przekroczenie lokalnego deadline'u +czekania. Same w sobie nie dowodzą, że zdalny workflow KSeF się nie udał. + +| Klasa | `code` | Atrybuty identyfikujące | +| --- | --- | --- | +| `KSeFAuthPollingTimeoutError` | `AUTH_POLLING_TIMEOUT` | `reference_number`, `timeout` | +| `KSeFTokenStatusTimeoutError` | `TOKEN_STATUS_TIMEOUT` | `reference_number`, `timeout` | +| `KSeFInvoiceQueryTimeoutError` | `INVOICE_QUERY_TIMEOUT` | `timeout` | +| `KSeFInvoiceDownloadTimeoutError` | `INVOICE_DOWNLOAD_TIMEOUT` | `ksef_number`, `timeout` | +| `KSeFInvoiceProcessingTimeoutError` | `INVOICE_PROCESSING_TIMEOUT` | `invoice_reference_number`, `timeout` | +| `KSeFExportTimeoutError` | `EXPORT_TIMEOUT` | `reference_number`, `timeout` | +| `KSeFBatchSessionTimeoutError` | `BATCH_SESSION_TIMEOUT` | `reference_number`, `timeout` | + +Zapisz właściwą referencję przed pollingiem, aby inny proces mógł wznowić +sprawdzanie statusu. + +## Atrybuty rate limitu + +| Atrybut | Typ | Znaczenie | +| --- | --- | --- | +| `retry_after` | `int | None` | Sekundy z `Retry-After` KSeF; `None`, gdy nagłówek jest nieobecny. | +| `status_code` | `int` | Zawsze `429`. | +| `response` | `BaseModel | None` | Sparsowany payload błędu KSeF, gdy jest dostępny. | + +## Powiązana referencja + + + + diff --git a/docs/pl/reference/low-level/authentication.mdx b/docs/pl/reference/low-level/authentication.mdx new file mode 100644 index 0000000..92c3dcd --- /dev/null +++ b/docs/pl/reference/low-level/authentication.mdx @@ -0,0 +1,96 @@ +--- +title: Low-level uwierzytelnianie +description: Metody endpointów auth raw, kontrakty sekwencji token i XAdES oraz wiązanie tokenów z SDK. +--- + +import { LinkCard } from '@astrojs/starlight/components'; + +Użyj low-level uwierzytelniania, gdy inny system posiada podpis, szyfrowanie +tokenu albo politykę pollingu. Raw authentication używa schema-native modeli +odpowiedzi, dopóki jawnie nie zmapujesz tokenów z powrotem do publicznego +modelu SDK. + +## Metody endpointów + +| Metoda | Transport | Zwraca | Do czego służy | +| --- | --- | --- | --- | +| `client.raw.auth.challenge()` | nieuwierzytelniony | `spec.AuthenticationChallengeResponse` | Utworzenie challenge dla tokenu albo XAdES. | +| `client.raw.auth.token_auth(body)` | nieuwierzytelniony | `spec.AuthenticationInitResponse` | Start uwierzytelniania tokenem KSeF przez `InitTokenAuthenticationRequest`. | +| `client.raw.auth.xades_auth(signed_xml, verify_chain=False)` | nieuwierzytelniony | `spec.AuthenticationInitResponse` | Wysłanie podpisanego zewnętrznie XML XAdES. | +| `client.raw.auth.auth_status(bearer_token, reference_number)` | tymczasowy bearer auth | `spec.AuthenticationOperationStatusResponse` | Polling statusu operacji uwierzytelniania. | +| `client.raw.auth.redeem_token(bearer_token)` | tymczasowy bearer auth | `spec.AuthenticationTokensResponse` | Wymiana tymczasowego tokenu auth na access i refresh token. | +| `client.raw.auth.refresh_token(bearer_token)` | refresh bearer | `spec.AuthenticationTokenRefreshResponse` | Odświeżenie access tokenu. | +| `auth.raw.auth.list_sessions(...)` | uwierzytelniony | `spec.AuthenticationListResponse` | Lista sesji uwierzytelnienia. | +| `auth.raw.auth.terminate_current_session()` | uwierzytelniony | `None` | Zamknięcie bieżącej sesji uwierzytelnienia. | +| `auth.raw.auth.terminate_auth_session(reference_number)` | uwierzytelniony | `None` | Zamknięcie innej sesji po referencji. | + +Klienci async mają te same nazwy metod i wymagają `await` dla wywołań +sieciowych. + +## Sekwencja token authentication + +| Krok | Wywołanie albo obiekt | Zachowaj | +| --- | --- | --- | +| Utwórz challenge | `client.raw.auth.challenge()` | `challenge.challenge`, `challenge.timestampMs` | +| Pobierz certyfikat do szyfrowania tokenu | `client.raw.encryption.fetch_public_certificates()` | certyfikat z usage `KsefTokenEncryption` | +| Zaszyfruj payload tokenu | `encrypt_token(ksef_token, str(challenge.timestampMs), cert.certificate)` | zaszyfrowany payload tokenu | +| Zbuduj request | `spec.InitTokenAuthenticationRequest(...)` | `challenge`, `contextIdentifier`, `encryptedToken`, `publicKeyId` | +| Rozpocznij operację | `client.raw.auth.token_auth(request)` | `init.authenticationToken.token`, `init.referenceNumber` | +| Polling | `client.raw.auth.auth_status(...)` | końcowy status | +| Redeem | `client.raw.auth.redeem_token(init.authenticationToken.token)` | schema-native odpowiedź access/refresh | +| Powiąż z SDK | `client.authentication.resume(AuthenticationResumeState.from_tokens(auth_mapper.from_spec(raw_tokens)))` | `AuthenticatedClient` | + +## Sekwencja XAdES z podpisem zewnętrznym + +| Krok | Wywołanie albo obiekt | Zachowaj | +| --- | --- | --- | +| Utwórz challenge | `client.raw.auth.challenge()` | payload challenge dla systemu podpisu | +| Podpisz poza SDK | HSM, gateway albo zewnętrzny signer | podpisany XML jako bajty | +| Rozpocznij operację | `client.raw.auth.xades_auth(signed_xml, verify_chain=True)` | `init.authenticationToken.token`, `init.referenceNumber` | +| Polling | `client.raw.auth.auth_status(...)` | końcowy status | +| Redeem | `client.raw.auth.redeem_token(init.authenticationToken.token)` | schema-native odpowiedź access/refresh | +| Powiąż z SDK | `client.authentication.resume(AuthenticationResumeState.from_tokens(auth_mapper.from_spec(raw_tokens)))` | `AuthenticatedClient` | + +## Wiązanie raw tokenów + +```python +from ksef2.models import AuthenticationResumeState +from ksef2.raw.mappers import auth as auth_mapper + +raw_tokens = client.raw.auth.redeem_token(init.authenticationToken.token) +auth_tokens = auth_mapper.from_spec(raw_tokens) +auth = client.authentication.resume(AuthenticationResumeState.from_tokens(auth_tokens)) +``` + +Po powiązaniu możesz używać gałęzi workflow, takich jak `auth.invoices`, albo +uwierzytelnionych gałęzi raw, takich jak `auth.raw.invoices`. + +## Kontrakt statusu pollingu + +`auth_status()` używa tymczasowego tokenu auth jako bearer tokenu, nie finalnego +access tokenu. Polluj, aż KSeF zwróci końcowy status uwierzytelniania, a potem +wywołaj `redeem_token()` z tym samym tymczasowym tokenem. + +| Warunek statusu | Akcja callera | +| --- | --- | +| Status sukcesu | Wymień tymczasowy token auth przez `redeem_token()`. | +| Status błędu | Przerwij i pokaż kod/opis statusu. | +| Lokalny timeout | Zapisz `init.referenceNumber` i zdecyduj, czy inny worker ma kontynuować polling. | + +## Powiązana referencja + + + + diff --git a/docs/pl/reference/low-level/endpoint-map.mdx b/docs/pl/reference/low-level/endpoint-map.mdx new file mode 100644 index 0000000..23b3306 --- /dev/null +++ b/docs/pl/reference/low-level/endpoint-map.mdx @@ -0,0 +1,56 @@ +--- +title: Mapa endpointów low-level +description: Znajdź gałąź raw i grupę metod ksef2 dla wybranego obszaru KSeF. +--- + +Grupy endpointów low-level są cienkimi fasadami nad wrapperami endpointów SDK. +Są dostępne przez `client.raw` przed uwierzytelnieniem i `auth.raw` po +uwierzytelnieniu. + +## Przed uwierzytelnieniem + +| Gałąź | Metody | +| --- | --- | +| `client.raw.auth` | `challenge`, `token_auth`, `xades_auth`, `auth_status`, `redeem_token`, `refresh_token` | +| `client.raw.encryption` | `fetch_public_certificates` | +| `client.raw.peppol` | `query_providers` | +| `client.raw.testdata` | `create_subject`, `delete_subject`, `create_person`, `delete_person`, `grant_permissions`, `revoke_permissions`, `enable_attachments`, `revoke_attachments`, `block_context`, `unblock_context` | + +`client.raw.testdata` jest dostępne tylko w `Environment.TEST`. + +## Po uwierzytelnieniu + +| Gałąź | Metody | +| --- | --- | +| `auth.raw.auth` | `list_sessions`, `terminate_current_session`, `terminate_auth_session`; także metody auth, gdy są związane z uwierzytelnionym transportem | +| `auth.raw.certificates` | `get_limits`, `get_enrollment_data`, `enroll`, `get_enrollment_status`, `retrieve`, `revoke`, `query` | +| `auth.raw.encryption` | `fetch_public_certificates` | +| `auth.raw.invoices` | `query_metadata`, `export`, `get_export_status`, `download`, `send`, `get_session_status`, `list_session_invoices`, `list_failed_session_invoices`, `get_session_invoice_status`, `get_invoice_upo_by_ksef`, `get_invoice_upo_by_reference` | +| `auth.raw.limits` | `get_context_limits`, `get_subject_limits`, `get_api_rate_limits`, `set_session_limits`, `reset_session_limits`, `set_subject_limits`, `reset_subject_limits`, `set_api_rate_limits`, `reset_api_rate_limits`, `set_production_rate_limits` | +| `auth.raw.peppol` | `query_providers` | +| `auth.raw.permissions.grant` | `grant_person`, `grant_entity`, `grant_authorization`, `grant_indirect`, `grant_subunit`, `grant_administered_eu_entity`, `grant_eu_entity` | +| `auth.raw.permissions.revoke` | `revoke_person`, `revoke_authorization` | +| `auth.raw.permissions.query` | `query_entities_grants`, `query_personal_grants`, `query_attachments_status`, `query_authorizations_grants`, `query_eu_entities_grants`, `query_persons_grants`, `query_subordinate_entities_roles`, `query_subunits_grants` | +| `auth.raw.permissions.status` | `query_operation_status`, `query_entity_roles` | +| `auth.raw.session` | `open_online`, `terminate_online`, `open_batch`, `close_batch`, `get_session_upo`, `list_sessions` | +| `auth.raw.testdata` | Te same metody co `client.raw.testdata`; dostępne tylko w `Environment.TEST`. | +| `auth.raw.tokens` | `generate_token`, `list_tokens`, `token_status`, `revoke_token` | + +## Importy + +```python +from ksef2.raw import ( + encrypt_invoice, + encrypt_symmetric_key, + encrypt_token, + generate_session_key, + prepare_batch_package, + sha256_b64, + spec, + supp, +) +from ksef2.raw.mappers import auth as auth_mapper +``` + +Używaj `spec` i `supp` dla wspieranych modeli schema-native. Nie importuj +wygenerowanych modeli z `ksef2.infra.schema.api` w kodzie aplikacyjnym. diff --git a/docs/pl/reference/low-level/overview.mdx b/docs/pl/reference/low-level/overview.mdx new file mode 100644 index 0000000..c133bfb --- /dev/null +++ b/docs/pl/reference/low-level/overview.mdx @@ -0,0 +1,81 @@ +--- +title: Low-level API +description: Schema-native wrappery endpointów, przestrzenie nazw modeli raw i eksportowane utility low-level. +--- + +import { LinkCard } from '@astrojs/starlight/components'; + +Low-level API to powierzchnia SDK dla kontroli na poziomie endpointów bez +wychodzenia poza transport SDK. Jest dostępna przez `client.raw` przed +uwierzytelnieniem i `auth.raw` po uwierzytelnieniu. + +Użyj jej, gdy integracja KSeF potrzebuje własnego podpisywania, własnej custody +szyfrowania, dokładnych payloadów OpenAPI albo debugowania endpointów. Większość +workflow aplikacyjnych powinna nadal zaczynać od klientów wysokopoziomowych. + +## Kontrakt + +| Właściwość | Zachowanie low-level | +| --- | --- | +| Transport | Używa tego samego stosu middleware klienta głównego, retry, lifecycle checks i mapowania wyjątków. | +| Uwierzytelnienie | `client.raw` jest nieuwierzytelnione; `auth.raw` używa bearer transportu tam, gdzie wymaga tego KSeF. | +| Modele | Requesty i odpowiedzi używają schema-native nazw KSeF/OpenAPI, takich jak `referenceNumber`, `publicKeyId` i `authenticationToken`. | +| Parsowanie | Poprawne odpowiedzi JSON są parsowane do wygenerowanych modeli `spec` albo supplemental `supp`. | +| Odpowiedzi binarne | XML faktur i UPO są zwracane jako `bytes`. | +| Async | Klient async ma te same gałęzie i nazwy metod, a wywołania sieciowe używają `await`. | + +## Przestrzenie nazw modeli + +Modele low-level importuj ze wspieranego `ksef2.raw`, nie z wewnętrznych +pakietów wygenerowanych: + +```python +from ksef2.raw import spec, supp +``` + +| Namespace | Do czego służy | +| --- | --- | +| `spec` | Wygenerowane modele żądań i odpowiedzi OpenAPI. | +| `supp` | Modele supplemental używane tam, gdzie wygenerowany kształt OpenAPI wymaga wsparcia SDK. | +| `ksef2.raw.mappers` | Jawne mosty z modeli schema-native do publicznych modeli SDK. | + +## Eksportowane utility + +| Export | Do czego służy | +| --- | --- | +| `encrypt_token` | Szyfrowanie payloadów uwierzytelniania tokenem KSeF. | +| `generate_session_key` | Generowanie klucza AES i IV dla szyfrowania sesji, faktur albo eksportu. | +| `encrypt_symmetric_key` | Szyfrowanie lokalnego materiału AES publicznym certyfikatem KSeF. | +| `encrypt_invoice` | Szyfrowanie bajtów XML faktury dla low-level wysyłki online. | +| `sha256_b64` | Obliczanie base64 SHA-256 dla metadanych payloadów low-level. | +| `prepare_batch_package` | Budowanie zaszyfrowanej paczki batch, gdy caller prowadzi low-level batch flow. | + +## Poziomy kontroli + +| Poziom | Co posiada SDK | Co posiada caller | Typowe wejście | +| --- | --- | --- | --- | +| Workflow | Kolejność endpointów, szyfrowanie, polling i mapowanie modeli. | Dane biznesowe i persystencja. | `auth.invoices`, `auth.batch`, klienci sesji. | +| Step-level | Szczegóły protokołu dla jednego kroku workflow SDK. | Kolejność i zapis stanu między krokami. | `session.send_invoice()`, `session.wait_for_invoice_ready()`. | +| Low-level | Transport, parsowanie odpowiedzi, mapowanie wyjątków. | Kolejność endpointów, schema-native payloady, custody szyfrowania, polling. | `client.raw`, `auth.raw`. | + +Główna zasada dotyczy właściciela sesji: jeśli low-level kod otwiera sesję, ten +sam poziom zwykle powinien ją zamknąć i sprawdzać. Jeśli wysoki poziom otwiera +sesję, używaj zwróconego klienta sesji. + +## Sekcja low-level + + + + diff --git a/docs/pl/reference/low-level/sessions-invoices.mdx b/docs/pl/reference/low-level/sessions-invoices.mdx new file mode 100644 index 0000000..ef57163 --- /dev/null +++ b/docs/pl/reference/low-level/sessions-invoices.mdx @@ -0,0 +1,119 @@ +--- +title: Low-level sesje i faktury +description: Kontrakty raw sesji, faktur, UPO, eksportu i payloadów szyfrowania. +--- + +import { LinkCard } from '@astrojs/starlight/components'; + +Użyj low-level sesji i faktur, gdy integracja posiada materiał szyfrowania, +otwieranie sesji, wysyłkę faktury, szyfrowanie eksportu albo kolejność pollingu. + +## Materiał posiadany przez callera + +| Wartość | Źródło | Potrzebna do | +| --- | --- | --- | +| `aes_key` | `generate_session_key()` albo własna custody klucza | Szyfrowanie faktury/sesji/eksportu i późniejsze odszyfrowanie. | +| `iv` | `generate_session_key()` albo własna custody klucza | Metadane szyfrowania wysyłane do KSeF i lokalne operacje crypto. | +| `encrypted_key` | `encrypt_symmetric_key(aes_key, cert.certificate)` | `encryptedSymmetricKey` w payloadach sesji albo eksportu. | +| `public_key_id` | Metadane publicznego certyfikatu KSeF | `publicKeyId` w payloadach sesji albo eksportu. | +| `referenceNumber` | Odpowiedź KSeF na open-session albo export | Późniejsza wysyłka, status, zamknięcie, UPO albo export status. | + +Jeżeli wysokopoziomowy helper utworzył ten materiał, preferuj pasującą metodę +resume albo fetch wysokiego poziomu. Nie odtwarzaj stanu szyfrowania z logów. + +## Metody raw session + +| Metoda | Zwraca | Do czego służy | +| --- | --- | --- | +| `auth.raw.session.open_online(body)` | `spec.OpenOnlineSessionResponse` | Otwarcie sesji online z `OpenOnlineSessionRequest`. | +| `auth.raw.session.terminate_online(reference_number)` | `None` | Zamknięcie sesji online po referencji. | +| `auth.raw.session.open_batch(body)` | `spec.OpenBatchSessionResponse` | Otwarcie sesji batch z `OpenBatchSessionRequest`. | +| `auth.raw.session.close_batch(reference_number)` | `None` | Zamknięcie sesji batch po uploadzie wszystkich części. | +| `auth.raw.session.get_session_upo(reference_number, upo_reference_number)` | `bytes` | Pobranie bajtów UPO sesji. | +| `auth.raw.session.list_sessions(...)` | `spec.SessionsQueryResponse` | Query historii sesji z filtrami i opcjonalnym continuation tokenem. | + +## Metody raw invoices + +| Metoda | Zwraca | Do czego służy | +| --- | --- | --- | +| `auth.raw.invoices.query_metadata(body, **params)` | `spec.QueryInvoicesMetadataResponse` | Pobranie jednej strony metadanych z `spec.InvoiceQueryFilters`. | +| `auth.raw.invoices.export(body)` | `spec.ExportInvoicesResponse` | Start eksportu faktur z `InvoiceExportRequest`. | +| `auth.raw.invoices.get_export_status(reference_number)` | `spec.InvoiceExportStatusResponse` | Sprawdzenie gotowości paczki eksportu. | +| `auth.raw.invoices.download(ksef_number)` | `bytes` | Pobranie przetworzonego XML faktury po numerze KSeF. | +| `auth.raw.invoices.send(reference_number, body)` | `spec.SendInvoiceResponse` | Wysłanie jednej zaszyfrowanej faktury do otwartej sesji online. | +| `auth.raw.invoices.get_session_status(reference_number)` | `spec.SessionStatusResponse` | Pobranie statusu sesji online albo batch. | +| `auth.raw.invoices.list_session_invoices(reference_number, ...)` | `spec.SessionInvoicesResponse` | Lista zaakceptowanych/faktur sesji. | +| `auth.raw.invoices.list_failed_session_invoices(reference_number, ...)` | `spec.SessionInvoicesResponse` | Lista nieudanych faktur sesji. | +| `auth.raw.invoices.get_session_invoice_status(reference_number, invoice_reference_number)` | `spec.SessionInvoiceStatusResponse` | Status jednej faktury w sesji. | +| `auth.raw.invoices.get_invoice_upo_by_ksef(reference_number, ksef_number)` | `bytes` | Pobranie UPO faktury po numerze KSeF. | +| `auth.raw.invoices.get_invoice_upo_by_reference(reference_number, invoice_reference_number)` | `bytes` | Pobranie UPO faktury po referencji faktury w sesji. | + +Metody z `continuation_token` wysyłają go jako `x-continuation-token`. + +## Payload OpenOnlineSessionRequest + +| Pole | Źródło | +| --- | --- | +| `formCode` | `spec.FormCode(...)` pasujący do schemy faktury. | +| `encryption.encryptedSymmetricKey` | Base64 encoded `encrypted_key`. | +| `encryption.initializationVector` | Base64 encoded `iv`. | +| `encryption.publicKeyId` | Id publicznego klucza KSeF użytego do szyfrowania. | + +Zachowaj zwrócone `referenceNumber` razem z `aes_key` i `iv`. + +## Payload SendInvoiceRequest + +| Pole | Źródło | +| --- | --- | +| `invoiceHash` | `sha256_b64(invoice_xml)` | +| `invoiceSize` | `len(invoice_xml)` | +| `encryptedInvoiceHash` | `sha256_b64(encrypted_invoice)` | +| `encryptedInvoiceSize` | `len(encrypted_invoice)` | +| `encryptedInvoiceContent` | Base64 encoded zaszyfrowane bajty faktury. | + +`encrypt_invoice(xml_bytes=invoice_xml, key=aes_key, iv=iv)` tworzy +zaszyfrowane bajty faktury. Low-level send nie polluje; użyj +`get_session_invoice_status()`, aż faktura osiągnie finalny status. + +## Payload eksportu + +`auth.raw.invoices.export()` przyjmuje `InvoiceExportRequest`. Caller posiada +te same pola szyfrowania co przy sesjach: + +| Grupa pól | Źródło | +| --- | --- | +| `encryption` | Zaszyfrowany materiał AES i `publicKeyId`. | +| `filters` | `spec.InvoiceQueryFilters`. | +| `compressionType` | `spec.CompressionType`, zwykle ZIP. | + +Używaj wysokopoziomowego `auth.invoices.fetch_package_bytes(...)` tylko wtedy, +gdy nadal masz klucz AES i IV wymagane do odszyfrowania części paczki. + +## Handoff paczki batch + +`prepare_batch_package` jest eksportowane z `ksef2.raw` dla low-level batch +flows. Przygotowuje metadane zaszyfrowanej paczki ZIP i części. Wynikowe +metadane batch file można przekazać do: + +| Punkt wejścia | Kiedy używać | +| --- | --- | +| `auth.raw.session.open_batch(body)` | Gdy chcesz posiadać pełny schema-native request otwarcia batch. | +| `auth.open_batch_session(...)` | Gdy chcesz step-level wiązanie sesji SDK przy caller-owned materiale szyfrowania. | + +## Powiązana referencja + + + + diff --git a/docs/pl/reference/operations.mdx b/docs/pl/reference/operations.mdx new file mode 100644 index 0000000..03c2784 --- /dev/null +++ b/docs/pl/reference/operations.mdx @@ -0,0 +1,208 @@ +--- +title: Referencja operacji +description: Konfiguracja transportu, retry, timeouty workflow, pola logowania i wznawialne referencje KSeF. +--- + +import { LinkCard } from '@astrojs/starlight/components'; + +Ta strona zapisuje zachowania operacyjne ważne dla integracji produkcyjnych. +Nie jest walkthrough wysyłki ani uwierzytelniania. + +## TransportConfig + +`TransportConfig` jest używany tylko wtedy, gdy SDK tworzy wewnętrznego klienta +`httpx`. Jeśli przekażesz `http_client`, ten klient posiada timeouty HTTP, pool, +TLS, proxy, `trust_env`, HTTP/2, custom transport i event hooki. + +```python +TransportConfig( + timeouts=TimeoutConfig(), + pool=ConnectionPoolConfig(), + retry=RetryConfig(), + tls=TlsConfig(), + proxy_url=None, + trust_env=True, + http2=True, +) +``` + +| Pole | Typ | Domyślnie | Przekazywane do | +| --- | --- | --- | --- | +| `timeouts` | `TimeoutConfig` | `TimeoutConfig()` | `httpx.Timeout` | +| `pool` | `ConnectionPoolConfig` | `ConnectionPoolConfig()` | `httpx.Limits` | +| `retry` | `RetryConfig` | `RetryConfig()` | Middleware retry SDK | +| `tls` | `TlsConfig` | `TlsConfig()` | Weryfikacja TLS w `httpx` | +| `proxy_url` | `str | None` | `None` | Proxy `httpx` | +| `trust_env` | `bool` | `True` | Obsługa zmiennych środowiskowych przez `httpx` | +| `http2` | `bool` | `True` | Flaga HTTP/2 w `httpx` | + +## TimeoutConfig + +`TimeoutConfig` kontroluje pojedynczy request HTTP. Nie kontroluje tego, jak +długo SDK odpytuje stan workflow KSeF. + +| Pole | Domyślnie | Znaczenie | +| --- | --- | --- | +| `connect` | `5.0` | Czas na zestawienie połączenia. | +| `read` | `30.0` | Czas oczekiwania na bajty odpowiedzi. | +| `write` | `30.0` | Czas wysyłania bajtów requestu. | +| `pool` | `5.0` | Czas oczekiwania na połączenie z poola. | + +## ConnectionPoolConfig + +| Pole | Domyślnie | Znaczenie | +| --- | --- | --- | +| `max_connections` | `100` | Maksymalna liczba otwartych połączeń. | +| `max_keepalive_connections` | `20` | Maksymalna liczba bezczynnych połączeń keep-alive. | +| `keepalive_expiry` | `30.0` | Wygaśnięcie bezczynnego keep-alive w sekundach. | + +## TlsConfig + +| Pole | Domyślnie | Znaczenie | +| --- | --- | --- | +| `verify` | `True` | Przekazywane do `httpx`, chyba że ustawiono `ca_bundle_path`. | +| `ca_bundle_path` | `None` | Ścieżka bundle CA używana jako wartość `verify` w `httpx`. | + +## RetryConfig + +`RetryConfig` kontroluje middleware retry SDK. Obejmuje błędy transportu i +ponawialne odpowiedzi HTTP dla ponawialnych typów requestów. + +| Pole | Domyślnie | +| --- | --- | +| `max_attempts` | `3` | +| `initial_delay` | `0.5` | +| `max_delay` | `4.0` | +| `backoff_multiplier` | `2.0` | +| `retryable_status_codes` | `(429, 502, 503, 504)` | + +Opóźnienie retry jest wykładnicze: + +```text +min(initial_delay * backoff_multiplier ** (attempt - 1), max_delay) +``` + +Jeżeli KSeF wyśle `Retry-After`, middleware retry użyje tej wartości, ale nie +większej niż `max_delay`. + +## Ponawialne requesty + +SDK ponawia tylko kształty requestów, które są wystarczająco bezpieczne do +powtórzenia na poziomie transportu. + +| Typ requestu | Zachowanie retry | +| --- | --- | +| `GET` | Ponawialny. | +| `DELETE` | Ponawialny. | +| Większość `POST` | Nie jest ponawiana automatycznie. | +| Wybrane `POST` | Ponawiane automatycznie, gdy są na liście niżej. | + +Ponawialne grupy `POST`: + +| Obszar | Operacje | +| --- | --- | +| Uwierzytelnianie | challenge, redeem token, refresh token | +| Faktury | query metadanych | +| Certyfikaty | query, retrieve | +| Uprawnienia | query personal, authorization, EU entity, person, subordinate entity i subunit | + +Wysyłka faktury, nadawanie uprawnień, enrollment certyfikatu, generowanie +tokenu, otwieranie sesji online i otwieranie sesji batch nie są ponawiane przez +middleware retry SDK. Zapisz zwrócone referencje i wznawiaj przez status zamiast +tworzyć te operacje ponownie w ciemno. + +## Timeouty pollingu workflow + +Helpery pollingowe używają argumentów `timeout` i `poll_interval`. Są to +deadline'y workflow, nie timeouty requestu HTTP. Gdy zostanie rzucony timeout +pollingu, zdalny workflow KSeF może nadal zakończyć się później. + +| Workflow | Wyjątek timeout | Identyfikator w wyjątku | +| --- | --- | --- | +| Polling uwierzytelniania | `KSeFAuthPollingTimeoutError` | `reference_number` | +| Polling aktywacji/statusu tokenu | `KSeFTokenStatusTimeoutError` | `reference_number` | +| Polling widoczności metadanych | `KSeFInvoiceQueryTimeoutError` | tylko `timeout` | +| Gotowość pobrania faktury | `KSeFInvoiceDownloadTimeoutError` | `ksef_number` | +| Przetwarzanie faktury online | `KSeFInvoiceProcessingTimeoutError` | `invoice_reference_number` | +| Gotowość paczki eksportu | `KSeFExportTimeoutError` | `reference_number` | +| Zakończenie sesji batch | `KSeFBatchSessionTimeoutError` | `reference_number` | + +Po timeoutcie wznów polling zapisanymi identyfikatorami. Nie traktuj lokalnego +deadline'u jako dowodu, że zdalna operacja się nie udała. + +## Rate limit + +Odpowiedzi KSeF `429` są klasyfikowane jako `KSeFRateLimitError`. Wyjątek +udostępnia: + +| Atrybut | Znaczenie | +| --- | --- | +| `retry_after` | Liczba sekund z `Retry-After`, jeśli KSeF zwrócił nagłówek; inaczej `None`. | +| `status_code` | Zawsze `429`. | +| `response` | Sparsowany payload błędu KSeF, jeśli jest dostępny. | + +Używaj `retry_after` do planowania pracy w tle. W request handlerach lepiej +zwrócić albo zakolejkować pracę do ponowienia niż usypiać request. + +## Granice sekretów i logowania + +Nie loguj: + +- access tokenów; +- refresh tokenów; +- wartości tokenów KSeF; +- kluczy prywatnych; +- haseł PEM albo PKCS#12; +- surowego XML faktury, chyba że polityka retencji na to pozwala; +- zserializowanego stanu sesji online albo batch. + +Przydatne pola logów produkcyjnych: + +| Pole | Znaczenie | +| --- | --- | +| `environment` | `test`, `demo` albo `production`. | +| `workflow` | Nazwa operacji na poziomie aplikacji. | +| `session_reference_number` | Referencja sesji online albo batch. | +| `invoice_reference_number` | Referencja faktury w sesji. | +| `ksef_number` | Finalny numer KSeF po przyjęciu faktury. | +| `operation_reference_number` | Referencja operacji tokenu, uprawnienia, certyfikatu, auth albo eksportu. | +| `sdk_error_code` | `KSeFException.context["code"]`, gdy istnieje. | +| `exception_code` | Kod wyjątku KSeF z `KSeFApiError.exception_code`, gdy istnieje. | + +## Wznawialny stan workflow + +Większość workflow KSeF zaczyna się referencją i kończy przez polling albo +późniejsze pobranie. Zapisz referencję przed czekaniem. + +| Workflow | Zapisz przed czekaniem | +| --- | --- | +| Uwierzytelnianie | referencję operacji auth i czas życia tymczasowego tokenu auth | +| Wysyłka faktury online | referencję sesji i numer referencyjny faktury | +| Upload batch | referencję sesji batch, referencje uploadu, metadane paczki i stan batch, dopóki jest potrzebny | +| Eksport faktur | numer referencyjny eksportu i chroniony materiał uchwytu eksportu | +| Generowanie tokenu | numer referencyjny tokenu i zwrócony sekret tokenu | +| Nadanie/cofnięcie uprawnień | numer referencyjny operacji uprawnień | +| Enrollment/revoke certyfikatu | referencję operacji certyfikatu albo numer seryjny certyfikatu | + +## Powiązana referencja + + + + + diff --git a/docs/pl/guides/public-api.md b/docs/pl/reference/public-api.mdx similarity index 70% rename from docs/pl/guides/public-api.md rename to docs/pl/reference/public-api.mdx index 31f7cf1..fb2ae5e 100644 --- a/docs/pl/guides/public-api.md +++ b/docs/pl/reference/public-api.mdx @@ -3,6 +3,8 @@ title: Kontrakt publicznego API description: Stabilne ścieżki importu i granice internal dla ksef2 1.0. --- +import { LinkCard } from '@astrojs/starlight/components'; + W kodzie aplikacyjnym używaj ścieżek importu z tej strony. To one mają pozostać stabilne w linii 1.x. @@ -12,19 +14,21 @@ stabilne w linii 1.x. | --- | --- | | `ksef2` | Klienci root, konfiguracja środowiska i transportu, `FormSchema`, `__version__` oraz publiczne wyjątki. | | `ksef2.clients` | Konkretne klasy klientów sync i async, gdy potrzebujesz ich w adnotacjach typów. | -| `ksef2.domain.models` | Modele żądań, odpowiedzi, filtrów, paginacji, tokenów, uprawnień, sesji i batchy. | +| `ksef2.models` | Modele żądań, odpowiedzi, filtrów, paginacji, tokenów, uprawnień, sesji i batchy. | +| `ksef2.fa3` | Publiczny builder faktur FA(3), drafty buildera oraz typy modelu faktury używane przez aplikacje. | | `ksef2.xades` | Ładowanie certyfikatów, generowanie certyfikatów TEST, lokalne podpisy XAdES i `LocalSigner`. | | `ksef2.profiles` | Helpery konfiguracji profili zgodnych z `ksef2-cli`. | +| `ksef2.renderers` | Opcjonalne lokalne helpery renderowania faktur XSLT/PDF. | | `ksef2.raw` | Low-level klienty endpointów, schema-native modele `spec` i `supp` oraz helpery kryptograficzne. | | `ksef2.raw.mappers` | Publiczne mappery między modelami raw i modelami SDK. | -| `ksef2.services` | Wysokopoziomowe klasy serwisów, gdy tworzysz je ręcznie zamiast używać gałęzi klienta. | -| `ksef2.services.renderers` | Opcjonalne helpery renderowania faktur XSLT/PDF. | Preferuj najwyższy poziom importu pasujący do workflow: ```python from ksef2 import Client, Environment, FormSchema, KSeFApiError -from ksef2.domain.models import InvoicesFilter, InvoiceMetadataParams +from ksef2.fa3 import FA3InvoiceBuilder, KsefInvoiceDraft, VatRate +from ksef2.models import InvoicesFilter, InvoiceMetadataParams +from ksef2.renderers import InvoicePDFExporter, InvoiceXSLTRenderer from ksef2.xades import load_certificate_from_pem, load_private_key_from_pem ``` @@ -63,6 +67,8 @@ Unikaj tych ścieżek w kodzie aplikacyjnym: - `ksef2.infra.*`: wygenerowane schemy i wewnętrzne mappery; - `ksef2.endpoints.*`: implementacja endpointów i transportu; - `ksef2.core.*`: wewnętrzne moduły protokołu, transportu, middleware i helperów; +- `ksef2.services.*`: implementacja usług komponowanych przez klienta; używaj + gałęzi klienta albo `ksef2.renderers`; - `scripts/*`: narzędzia repozytorium, nie API pakietu. Część ścieżek internal może być importowalna dla implementacji SDK albo testów, @@ -76,6 +82,18 @@ zachować udokumentowane importy i zachowanie poza poprawkami błędów. ## Referencja -- [Klient](client.md) -- [Low-level API](../raw/overview.md) -- [Generowanie kodu sync](../contributing/sync-generation.md) + + + diff --git a/docs/pl/reference/release-notes-1-0-0.mdx b/docs/pl/reference/release-notes-1-0-0.mdx new file mode 100644 index 0000000..3e944ee --- /dev/null +++ b/docs/pl/reference/release-notes-1-0-0.mdx @@ -0,0 +1,121 @@ +--- +title: Release notes ksef2 1.0.0 +description: Granica stabilności, wspierane przepływy i zmiany względem wersji pre-1.0 dla pierwszego stabilnego wydania SDK. +--- + +import { Aside, CardGrid, LinkCard } from '@astrojs/starlight/components'; + + + +ksef2 1.0.0 to pierwsze wydanie, które traktuje udokumentowane, aplikacyjne +ścieżki importu jako kontrakt kompatybilności dla linii 1.x. SDK obecnie celuje +w wersję OpenAPI KSeF `2.6.1`. + +> **Nieoficjalne SDK.** ksef2 jest utrzymywane społecznościowo. Nie jest +> publikowane, zatwierdzane ani wspierane przez Ministerstwo Finansów. Oficjalna +> dokumentacja KSeF pozostaje źródłem prawdy dla zachowania API. + + + +## Stabilne w 1.0 + +Stabilnym kontraktem jest udokumentowana publiczna powierzchnia SDK, a nie każdy +moduł, który da się zaimportować z repozytorium. + +| Powierzchnia | Kontrakt 1.0 | +| --- | --- | +| `ksef2` | Klienci root, środowiska, konfiguracja transportu, `FormSchema`, `__version__` i publiczne wyjątki. | +| `ksef2.clients` | Konkretne klasy klientów sync i async do adnotacji typów i zaawansowanego konstruowania. | +| `ksef2.models` | Publiczne modele SDK dla requestów, response'ów, filtrów, paginacji, tokenów, uprawnień, sesji, batchy i faktur. | +| Wysokopoziomowe gałęzie klientów | `client.authentication`, `client.encryption`, `client.peppol`, `client.testdata` oraz gałęzie uwierzytelnione takie jak `auth.invoices`, `auth.tokens`, `auth.permissions`, `auth.certificates` i `auth.limits`. | +| Pomocniki sesji | Przepływy sesji online i batch do wysyłki, pollingu, UPO i wznawialnych referencji KSeF. | +| `ksef2.xades` | Ładowanie certyfikatów, generowanie certyfikatów TEST, lokalne pomocniki podpisu XAdES i `LocalSigner`. | +| `ksef2.profiles` | Pomocniki konfiguracji profili zgodnych z lokalnymi profilami `ksef2-cli`. | +| `ksef2.fa3` | Publiczny builder faktur FA(3), drafty buildera i publiczne modele domenowe FA(3) używane przez przepływy buildera. | +| `ksef2.renderers` | Opcjonalne lokalne pomocniki renderowania faktur XSLT/PDF po instalacji dodatku `pdf`. | + +Dokładną granicę kompatybilności opisuje strona kontraktu publicznego API. + +## Publiczne, ale niższopoziomowe + +`ksef2.raw` i `ksef2.raw.mappers` są publicznymi API dla zaawansowanych +integracji. Ich ścieżki importu należą do kontraktu 1.x, ale kształty modeli +schema-native podążają za sprawdzoną wersją OpenAPI Ministerstwa Finansów. + +Używaj `ksef2.raw`, gdy potrzebujesz kontroli na poziomie endpointów, +dokładnych payloadów w kształcie OpenAPI, własnej kontroli nad szyfrowaniem +albo debugowania protokołu. Większość kodu aplikacyjnego powinna używać +wysokopoziomowych gałęzi klientów. + +## Poza kontraktem 1.x + +Nie buduj kodu aplikacji na tych ścieżkach: + +- `ksef2.infra.*`; +- `ksef2.core.*`; +- `ksef2.endpoints.*`; +- `ksef2.services.*`; +- repozytoryjne `scripts/*`; +- moduły z podkreśleniem i zdeprecjonowane moduły kompatybilności; +- internale wygenerowanych schem poza `ksef2.raw.spec` i `ksef2.raw.supp`. + +Te ścieżki mogą nadal być importowalne dla implementacji SDK, testów i +narzędzi, ale mogą zmieniać się bez wydania 2.0. + +## Co zmieniło się od pre-1.0 + +Wydania pre-1.0 ukształtowały SDK wokół stabilnej powierzchni aplikacyjnej: + +- normalne przepływy zostały skonsolidowane wokół `Client` albo `AsyncClient`, + potem `client.authentication`, potem gałęzi uwierzytelnionych takich jak + `auth.invoices`; +- powierzchnie klientów sync i async mają zgodne nazwy gałęzi; +- dodano publiczne fasady `ksef2.xades` i `ksef2.profiles`; +- dodano `ksef2.raw` dla dostępu endpointowego schema-native bez importowania + wewnętrznych modułów generowanych; +- udokumentowano publiczne klasy wyjątków, błędy API KSeF, rate limity i + lokalne timeouty pollingu; +- dodano paginację metadanych faktur, eksporty, pobieranie paczek i bezpośrednie + pobieranie faktur; +- dodano przepływy tokenów, uprawnień, certyfikatów, limitów, PEPPOL i danych + TEST; +- dodano publiczny builder FA(3) i API draftów pod `ksef2.fa3`; +- dokumentacja publiczna została podzielona na `getting-started`, `concepts`, + `how-to-guides` i `reference`. + +## Skrót migracji + +Przenieś importy aplikacji na udokumentowane ścieżki publiczne, przetestuj +uwierzytelnianie i przepływy faktur w TEST albo DEMO, a stare linki +dokumentacji zamień na aktualne trasy Starlight. + + + + + + + diff --git a/docs/pl/workflows/building-invoices.mdx b/docs/pl/workflows/building-invoices.mdx new file mode 100644 index 0000000..37ef4d7 --- /dev/null +++ b/docs/pl/workflows/building-invoices.mdx @@ -0,0 +1,114 @@ +--- +title: Budowanie faktur +description: Zbuduj XML faktury FA(3) typowanymi pomocnikami ksef2 przed wysyłką do KSeF. +--- + +import { Aside, Steps } from '@astrojs/starlight/components'; + +Użyj `ksef2.fa3`, gdy aplikacja ma dane faktury jako obiekty Pythona i chcesz, +żeby SDK wygenerowało XML FA(3). Użyj przepływów wysyłki, gdy masz już XML z +innego systemu. + +## Zbuduj fakturę standardową + +```python +from datetime import date +from decimal import Decimal + +from ksef2.fa3 import FA3InvoiceBuilder, VatRate + +builder = ( + FA3InvoiceBuilder() + .header(system_info="billing-service") + .seller( + name="ACME S.A.", + tax_id="1234567890", + country_code="PL", + address_line_1="ul. Przykladowa 123", + ) + .buyer( + name="XYZ GmbH", + country_code="DE", + address_line_1="Unter den Linden 1", + ) + .standard() + .issue_place("Warszawa") + .issue_date(date(2026, 3, 29)) + .invoice_number("FV/2026/03/0001") + .rows() + .add_line( + name="Consulting service", + supply_date=date(2026, 3, 29), + unit_of_measure="h", + quantity=Decimal("10"), + unit_price_net=Decimal("100.00"), + vat_rate=VatRate.VAT_23, + ) + .done() + .done() +) + +xml_bytes = builder.to_xml().encode("utf-8") +``` + +Każde `.done()` wraca do poprzedniego poziomu buildera. + +## Wybierz rodzaj faktury + +- `standard()` +- `simplified()` +- `correction()` +- `advance()` +- `settlement()` +- `correction_advance()` +- `correction_settlement()` + +## Zapisuj wersje robocze + +Zapisuj stan buildera, gdy edycja faktury może trwać przez wiele żądań albo +sesji użytkownika. + +```python +from ksef2.fa3 import FA3InvoiceBuilder, KsefInvoiceDraft + +json_text = builder.dump_state_json(indent=2) +draft = KsefInvoiceDraft.model_validate_json(json_text) +restored = FA3InvoiceBuilder.from_state(draft) +``` + +## Wyślij wynik buildera + +```python +from ksef2 import FormSchema + +with auth.online_session(form_code=FormSchema.FA3) as session: + status = session.send_invoice_and_wait(invoice_xml=xml_bytes) + print(status.ksef_number) +``` + + + +## Zalecany przepływ + + + +1. Przenieś dane aplikacji do buildera FA(3). + +2. Wybierz rodzaj faktury i uzupełnij wymagane sekcje zagnieżdżone. + +3. Zapisz stan draftu, jeśli użytkownicy mogą przerwać edycję. + +4. Wyrenderuj XML metodą `to_xml()`. + +5. Wyślij XML przez przepływ online albo batch. + + + +## Referencja + +- [Wysyłanie faktur](sending-invoices.mdx) +- [Referencja API FA(3)](../reference/api/fa3.md) diff --git a/docs/pl/workflows/certificates.mdx b/docs/pl/workflows/certificates.mdx deleted file mode 100644 index f607786..0000000 --- a/docs/pl/workflows/certificates.mdx +++ /dev/null @@ -1,94 +0,0 @@ ---- -title: Certyfikaty -description: Sprawdzaj limity certyfikatów, rejestruj certyfikaty, wyszukuj wydane certyfikaty i cofaj je. ---- - -import { Aside, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; - -Użyj `auth.certificates` do cyklu życia certyfikatów KSeF. SDK wysyła CSR i -żądania cyklu życia; aplikacja albo narzędzia certyfikatowe nadal odpowiadają za -generowanie kluczy i CSR. - -## Sprawdź limity i dane rejestracji - -```python -limits = auth.certificates.get_limits() -print(limits.can_request, limits.enrollment_remaining) - -subject = auth.certificates.get_enrollment_data() -print(subject.common_name, subject.country_name) -``` - -## Zarejestruj i odpytaj status - -```python -enrollment = auth.certificates.enroll( - certificate_name="billing-service", - certificate_type="authentication", - csr="-----BEGIN CERTIFICATE REQUEST-----\n...\n-----END CERTIFICATE REQUEST-----", -) - -status = auth.certificates.get_enrollment_status( - reference_number=enrollment.reference_number, -) -print(status.status_code, status.certificate_serial_number) -``` - -## Pobierz, wyszukaj i cofnij - - - - -```python -for certificate in auth.certificates.all(status="active"): - print(certificate.serial_number, certificate.name, certificate.valid_to) -``` - - - - -```python -result = auth.certificates.retrieve( - certificate_serial_numbers=["0123456789ABCDEF"], -) -``` - - - - -```python -auth.certificates.revoke( - certificate_serial_number="0123456789ABCDEF", - reason="key_compromise", -) -``` - - - - - - -## Zalecany przepływ - - - -1. Sprawdź limity certyfikatów i rejestracji. - -2. Pobierz dane podmiotu do rejestracji. - -3. Wygeneruj klucz prywatny i CSR poza SDK. - -4. Wyślij rejestrację i zapisz referencję. - -5. Odpytuj status, pobierz certyfikat i przechowuj go z jego kluczem prywatnym. - - - -## Referencja - -- [Pomocniki XAdES](xades.mdx) -- [API certyfikatów](../reference/api/certificates.md) diff --git a/docs/pl/workflows/client-setup.mdx b/docs/pl/workflows/client-setup.mdx index e8f85e8..bb968e6 100644 --- a/docs/pl/workflows/client-setup.mdx +++ b/docs/pl/workflows/client-setup.mdx @@ -37,6 +37,29 @@ async with AsyncClient(Environment.TEST) as client: Poza lokalnymi przepływami TEST używaj `Environment.DEMO` albo `Environment.PRODUCTION`. +## Cache certyfikatów + +Klienci główni domyślnie używają pamięciowego magazynu certyfikatów. Magazyn +odświeża publiczne certyfikaty szyfrowania po 24 godzinach, a wysokopoziomowe +przepływy uwierzytelniania, sesji i eksportu ładują certyfikaty leniwie, gdy ich +potrzebują. + +Ustaw własny interwał odświeżania, gdy aplikacja ma bardziej rygorystyczną +politykę startu lub rotacji: + +```python +from datetime import timedelta + +from ksef2 import CertificateStore, Client, Environment + +store = CertificateStore(refresh_after=timedelta(hours=6)) +client = Client(Environment.PRODUCTION, certificate_store=store) +``` + +Przekaż dowolny obiekt implementujący `CertificateStoreProtocol`, gdy +certyfikaty mają być współdzielone przez magazyn aplikacji, na przykład bazę +danych lub cache. + Gdy dane uwierzytelniające są zapisane w profilu kompatybilnym z CLI, utwórz klienta głównego dla środowiska profilu i uwierzytelnij się przez `with_profile()`: diff --git a/docs/pl/workflows/downloading-invoices.mdx b/docs/pl/workflows/downloading-invoices.mdx deleted file mode 100644 index c7247fa..0000000 --- a/docs/pl/workflows/downloading-invoices.mdx +++ /dev/null @@ -1,96 +0,0 @@ ---- -title: Pobieranie faktur -description: Pobieraj XML faktur bezpośrednio albo przez szyfrowane paczki eksportu ksef2. ---- - -import { Aside, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; - -Pobieraj bezpośrednio, gdy znasz numer KSeF jednej faktury. Eksportu użyj dla -większej liczby faktur albo gdy chcesz pobrać zaszyfrowane paczki. - -## Pobierz jedną fakturę - -```python -xml_bytes = auth.invoices.download_invoice(ksef_number="numer-KSeF") - -with open("invoice.xml", "wb") as handle: - handle.write(xml_bytes) -``` - -Jeśli faktura została dopiero wysłana, poczekaj aż KSeF udostępni przetworzony -XML do pobrania: - -```python -xml_bytes = auth.invoices.wait_for_invoice_download( - ksef_number="numer-KSeF", - timeout=120.0, - poll_interval=2.0, -) -``` - -## Eksport wielu faktur - -Eksport jest asynchroniczny. Zaplanuj eksport z filtrów, poczekaj na paczkę, a -następnie pobierz odszyfrowane części ZIP. - - - - -```python -export = auth.invoices.schedule_export(filters=filters) -package = auth.invoices.wait_for_export_package( - reference_number=export.reference_number, - timeout=300.0, -) - -for path in auth.invoices.fetch_package( - package=package, - export=export, - target_directory="downloads", -): - print(path) -``` - - - - -```python -zip_parts = auth.invoices.export_and_download( - filters=filters, - timeout=300.0, -) - -for part in zip_parts: - print(len(part)) -``` - - - - - - -## Po wysyłce faktur - -Jeśli przepływ wysyła i potem pobiera faktury, rozdziel te fazy: - - - -1. Wyślij XML przez sesję online albo wsadową. - -2. Zapisz numer KSeF albo referencję sesji. - -3. Odpytuj status sesji albo metadane do końca przetwarzania. - -4. Pobierz po numerze KSeF albo zbuduj filtr eksportu dla tego samego okresu. - - - -## Następne przepływy - -- [Wysyłanie faktur](sending-invoices.mdx) -- [Wyszukiwanie faktur](querying-invoices.mdx) -- [Invoice retrieval API](../reference/api/invoice-retrieval.md) diff --git a/docs/pl/workflows/encryption-certificates.mdx b/docs/pl/workflows/encryption-certificates.mdx index d25cc35..e3c4ef2 100644 --- a/docs/pl/workflows/encryption-certificates.mdx +++ b/docs/pl/workflows/encryption-certificates.mdx @@ -10,6 +10,11 @@ materiału eksportów. Wysokopoziomowe pomocniki faktur i batch ładują te certyfikaty automatycznie. Użyj `client.encryption` bezpośrednio, gdy chcesz sprawdzić, cache'ować albo wstępnie załadować materiał certyfikatu. +Domyślnie klienci główni używają `CertificateStore`, czyli pamięciowego cache +odświeżanego po 24 godzinach. Przekaż `refresh_after=None`, aby zachować +zachowanie "pobierz raz" dla krótkotrwałego klienta, albo przekaż własny obiekt +implementujący `CertificateStoreProtocol`, aby zintegrować magazyn aplikacji. + ## Pobierz certyfikaty ```python @@ -27,6 +32,23 @@ certificates = client.encryption.get_certificates( ) ``` +Skonfiguruj domyślny magazyn podczas tworzenia klienta głównego: + +```python +from datetime import timedelta + +from ksef2 import CertificateStore, Client, Environment + +client = Client( + Environment.PRODUCTION, + certificate_store=CertificateStore(refresh_after=timedelta(hours=6)), +) +``` + +Własne magazyny implementują `load()`, `get_valid()` i `needs_refresh()`. SDK +nadal odpowiada za pobieranie zdalne; magazyn odpowiada za trwałość cache i +decyzje o świeżości. + ## Jak SDK ich używa Uwierzytelnione sesje faktur i eksporty wywołują gałąź certyfikatów szyfrowania diff --git a/docs/pl/workflows/limits.mdx b/docs/pl/workflows/limits.mdx deleted file mode 100644 index 434451b..0000000 --- a/docs/pl/workflows/limits.mdx +++ /dev/null @@ -1,91 +0,0 @@ ---- -title: Limity -description: Odczytuj i nadpisuj limity kontekstu, podmiotu i API rate limitów KSeF przez ksef2. ---- - -import { Aside, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; - -Użyj `auth.limits`, żeby sprawdzić efektywne limity KSeF i, gdy pozwalają na to -poświadczenia, nadpisać limity dla TEST albo kontekstów administracyjnych. - -## Odczytaj efektywne limity - - - - -```python -context = auth.limits.get_context_limits() -print(context.online_session.max_invoices) -print(context.batch_session.max_invoice_size_mb) -``` - - - - -```python -subject = auth.limits.get_subject_limits() -print(subject.certificate, subject.enrollment) -``` - - - - -```python -rate = auth.limits.get_api_rate_limits() -print(rate.invoice_metadata.per_minute) -``` - - - - -## Nadpisz i zresetuj - -```python -from ksef2.domain.models.limits import ContextLimits, SessionLimits - -limits = ContextLimits( - online_session=SessionLimits( - max_invoice_size_mb=10, - max_invoice_with_attachment_size_mb=20, - max_invoices=100, - ), - batch_session=SessionLimits( - max_invoice_size_mb=10, - max_invoice_with_attachment_size_mb=20, - max_invoices=1000, - ), -) - -auth.limits.set_session_limits(limits=limits) -auth.limits.reset_session_limits() -``` - -Użyj `set_production_rate_limits()`, gdy środowisko podobne do TEST ma -odzwierciedlać produkcyjne domyślne API rate limity. - - - -## Zalecany przepływ - - - -1. Odczytaj efektywne limity przed wyborem wielkości batcha albo zachowania - pollingu. - -2. Używaj domyślnych limitów produkcyjnych, chyba że kontrolowany test wymaga - innej wartości. - -3. Stosuj nadpisania z jawnego przepływu administracyjnego. - -4. Resetuj limity po teście albo zmianie tymczasowej. - - - -## Referencja - -- [Wysyłanie faktur](sending-invoices.mdx) -- [API limitów](../reference/api/limits.md) diff --git a/docs/pl/workflows/peppol.mdx b/docs/pl/workflows/peppol.mdx deleted file mode 100644 index d7764ce..0000000 --- a/docs/pl/workflows/peppol.mdx +++ /dev/null @@ -1,49 +0,0 @@ ---- -title: Dostawcy PEPPOL -description: Wyszukuj dostawców usług PEPPOL zarejestrowanych w KSeF. ---- - -import { Steps, TabItem, Tabs } from '@astrojs/starlight/components'; - -Użyj `client.peppol`, żeby sprawdzać dostawców PEPPOL. To publiczna gałąź -klienta głównego i nie wymaga klienta uwierzytelnionego. - -## Wyszukaj dostawców - - - - -```python -page = client.peppol.query() - -for provider in page.providers: - print(provider.id, provider.name) -``` - - - - -```python -for provider in client.peppol.all(): - print(provider.id, provider.name, provider.date_created) -``` - - - - -## Zalecany przepływ - - - -1. Wyszukaj dostawców z klienta głównego. - -2. Cache'uj identyfikatory i nazwy dostawców do wyboru albo walidacji. - -3. Odświeżaj cache zgodnie z wymaganiami świeżości danych w produkcie. - - - -## Referencja - -- [Konfiguracja klienta](client-setup.mdx) -- [API PEPPOL](../reference/api/peppol.md) diff --git a/docs/pl/workflows/permissions.mdx b/docs/pl/workflows/permissions.mdx deleted file mode 100644 index 4e46e80..0000000 --- a/docs/pl/workflows/permissions.mdx +++ /dev/null @@ -1,121 +0,0 @@ ---- -title: Uprawnienia -description: Nadawaj, wyszukuj, odbieraj i monitoruj uprawnienia KSeF z ksef2. ---- - -import { Aside, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; - -Użyj `auth.permissions` do nadań, cofnięć, wyszukiwania i sprawdzania statusu -operacji uprawnień. Operacje zwracają referencje; odpytuj te referencje zanim -uznasz zmianę za zakończoną. - -## Nadaj uprawnienia - - - - -```python -operation = auth.permissions.grant_person( - subject_type="pesel", - subject_value="90010112345", - permissions=["invoice_read"], - description="Read invoices", - first_name="Jan", - last_name="Kowalski", -) -``` - - - - -```python -from ksef2.domain.models import EntityPermission - -operation = auth.permissions.grant_entity( - subject_value="1234567890", - permissions=[EntityPermission(type="invoice_read", can_delegate=False)], - description="Accounting office read access", - entity_name="Accounting Sp. z o.o.", -) -``` - - - - -```python -operation = auth.permissions.grant_authorization( - subject_type="nip", - subject_value="1234567890", - permission="self_invoicing", - description="Self-invoicing agreement", - entity_name="Partner Sp. z o.o.", -) -``` - - - - -## Sprawdź status operacji - -```python -status = auth.permissions.get_operation_status( - reference_number=operation.reference_number, -) -print(status.status.code, status.status.description) -``` - -## Wyszukaj i cofnij - -```python -from ksef2.domain.models import PersonalPermissionsQuery - -page = auth.permissions.query_personal( - query=PersonalPermissionsQuery(permission_types=["invoice_read"]), -) - -for permission in page.permissions: - print(permission.id, permission.permission_state) -``` - -Użyj zwróconego identyfikatora uprawnienia do cofnięcia: - -```python -auth.permissions.revoke_common(permission_id="permission-id") -auth.permissions.revoke_authorization(permission_id="authorization-id") -``` - -## Załączniki - -```python -status = auth.permissions.get_attachment_permission_status() -print(status.is_attachment_allowed) -``` - - - -## Zalecany przepływ - - - -1. Nadaj najmniejszy zestaw uprawnień wymagany przez docelowy podmiot. - -2. Zapisz referencję operacji zwróconą przez KSeF. - -3. Odpytaj status operacji przed pokazaniem uprawnienia w aplikacji. - -4. Wyszukaj uprawnienia, aby zebrać identyfikatory do audytu albo cofnięcia. - -5. Cofnij po identyfikatorze uprawnienia, gdy dostęp ma się zakończyć. - - - -## Referencja - -- [API nadań uprawnień](../reference/api/permission-grants.md) -- [API wyszukiwania uprawnień](../reference/api/permission-search.md) -- [API operacji uprawnień](../reference/api/permission-operations.md) -- [API cofania uprawnień](../reference/api/permission-revokes.md) diff --git a/docs/pl/workflows/querying-invoices.mdx b/docs/pl/workflows/querying-invoices.mdx deleted file mode 100644 index cb65a23..0000000 --- a/docs/pl/workflows/querying-invoices.mdx +++ /dev/null @@ -1,109 +0,0 @@ ---- -title: Wyszukiwanie faktur -description: Wyszukuj metadane faktur przez filtry, paginację i polling ksef2. ---- - -import { Aside, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; - -Użyj `auth.invoices`, gdy potrzebujesz metadanych faktur poza sesją wysyłki. -Zapytania o metadane są oparte o filtry i paginowane przez KSeF. - -## Zbuduj filtr - -Filtry powinny być wąskie. Zakres dat, rola i typ faktury ograniczają pracę po -stronie KSeF i liczbę stron wyników. - -```python -from datetime import datetime, timedelta, timezone - -from ksef2.domain.models import InvoicesFilter - -filters = InvoicesFilter( - role="seller", - date_type="issue_date", - date_from=datetime.now(tz=timezone.utc) - timedelta(days=7), - date_to=datetime.now(tz=timezone.utc), - amount_type="brutto", - invoicing_mode="online", - invoice_types=["vat"], -) -``` - -## Jedna strona albo wszystkie strony - - - - -```python -from ksef2.domain.models import InvoiceMetadataParams - -page = auth.invoices.query_metadata( - filters=filters, - params=InvoiceMetadataParams(sort_order="asc"), -) - -for invoice in page.invoices: - print(invoice.ksef_number, invoice.invoice_number) -``` - - - - -```python -for page in auth.invoices.query_metadata_pages(filters=filters): - print(len(page.invoices), page.has_more) -``` - - - - -```python -for invoice in auth.invoices.all_metadata(filters=filters): - print(invoice.ksef_number, invoice.invoice_number) -``` - - - - -## Poczekaj na nową fakturę - -Przetwarzanie w KSeF jest asynchroniczne. Po wysyłce odpytuj metadane, jeśli -następny krok zależy od widoczności faktury w API pobierania. - -```python -result = auth.invoices.wait_for_invoices( - filters=filters, - timeout=120.0, - poll_interval=2.0, -) - -for invoice in result.invoices: - print(invoice.ksef_number) -``` - - - -## Zalecany przepływ - - - -1. Wybierz rolę podmiotu, jako który wyszukujesz. - -2. Zbuduj wąski `InvoicesFilter`. - -3. Pobierz jedną stronę dla ekranów interaktywnych albo iteruj po stronach w - zadaniach w tle. - -4. Zapisz numery KSeF potrzebne do późniejszego pobrania. - - - -## Następne przepływy - -- [Wysyłanie faktur](sending-invoices.mdx) -- [Pobieranie faktur](downloading-invoices.mdx) -- [Invoice retrieval API](../reference/api/invoice-retrieval.md) diff --git a/docs/pl/workflows/sending-invoices.mdx b/docs/pl/workflows/sending-invoices.mdx deleted file mode 100644 index 7aa574c..0000000 --- a/docs/pl/workflows/sending-invoices.mdx +++ /dev/null @@ -1,158 +0,0 @@ ---- -title: Wysyłanie faktur -description: Wyślij XML FA(3) przez sesje online albo sesje wsadowe ksef2. ---- - -import { Aside, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; - -ksef2 obsługuje dwa przepływy wysyłki: - -- **Sesje online** dla krótkiej, interaktywnej wysyłki. -- **Sesje wsadowe** dla większych zestawów plików XML. - -Oba przepływy szyfrują faktury przed wysłaniem i zwracają referencje sesji, -których możesz użyć do sprawdzania statusu. - -## Sesja online - -Użyj sesji online, gdy wysyłasz jedną lub kilka faktur i chcesz dostać numer -KSeF w tym samym procesie. - -```python -from pathlib import Path - -from ksef2 import FormSchema - -with auth.online_session(form_code=FormSchema.FA3) as session: - status = session.send_invoice_and_wait( - invoice_xml=Path("invoice.xml").read_bytes(), - timeout=60.0, - poll_interval=2.0, - ) - print(status.ksef_number) -``` - -Po wyjściu z bloku kontekstowego sesja online zostanie zamknięta. - -## Ręczne odpytywanie - -Niższy poziom jest przydatny, gdy chcesz zapisać referencję faktury albo użyć -własnej polityki retry. - -```python -from pathlib import Path - -with auth.online_session(form_code=FormSchema.FA3) as session: - result = session.send_invoice(invoice_xml=Path("invoice.xml").read_bytes()) - print(result.reference_number) - - status = session.wait_for_invoice_ready( - invoice_reference_number=result.reference_number, - timeout=120.0, - ) - print(status.ksef_number) -``` - -## Sesja wsadowa - -Sesja wsadowa wysyła wiele plików XML jako jeden batch. Serwis może przygotować -paczkę, wysłać wszystkie części, zamknąć sesję i odpytywać status do końca -przetwarzania. - - - - -```python -from pathlib import Path - -from ksef2 import FormSchema - -prepared = auth.batch.prepare_batch_from_paths( - invoice_paths=[ - Path("invoice-1.xml"), - Path("invoice-2.xml"), - ], - form_code=FormSchema.FA3, -) - -state = auth.batch.submit_prepared_batch(prepared_batch=prepared) -final_status = auth.batch.wait_for_completion(session=state, timeout=300.0) -print(final_status.reference_number) -``` - - - - -```python -from pathlib import Path - -from ksef2 import FormSchema -from ksef2.domain.models import BatchInvoice - -state = auth.batch.submit_batch( - invoices=[ - BatchInvoice( - file_name="invoice-1.xml", - content=Path("invoice-1.xml").read_bytes(), - ), - BatchInvoice( - file_name="invoice-2.xml", - content=Path("invoice-2.xml").read_bytes(), - ), - ], - form_code=FormSchema.FA3, -) - -final_status = auth.batch.wait_for_completion(session=state, timeout=300.0) -print(final_status.reference_number) -``` - - - - -## Sprawdź wyniki - -```python -accepted = auth.batch.list_invoices(session=state) -failed = auth.batch.list_failed_invoices(session=state) - -print(len(accepted.invoices), len(failed.invoices)) -``` - -Jeśli status końcowy zwraca referencję UPO, pobierz zbiorcze UPO: - -```python -upo_xml = auth.batch.get_upo( - session=state, - upo_reference_number="referencja-upo-ze-statusu", -) -``` - - - -## Zalecany przepływ - - - -1. Uwierzytelnij się w kontekście sprzedawcy. - -2. Wybierz sesję online albo sesję wsadową. - -3. Wyślij XML i zapisz zwrócone referencje. - -4. Odpytuj KSeF do wyniku końcowego. - -5. Wyszukaj metadane albo pobierz XML po zakończeniu przetwarzania. - - - -## Następne przepływy - -- [Wyszukiwanie faktur](querying-invoices.mdx) -- [Pobieranie faktur](downloading-invoices.mdx) -- [Interactive sending API](../reference/api/interactive-sending.md) -- [Status and UPO API](../reference/api/status-upo.md) diff --git a/docs/pl/workflows/status-upo.mdx b/docs/pl/workflows/status-upo.mdx deleted file mode 100644 index 58672fc..0000000 --- a/docs/pl/workflows/status-upo.mdx +++ /dev/null @@ -1,95 +0,0 @@ ---- -title: Status i UPO -description: Sprawdzaj status sesji online i batch, listuj faktury sesji i pobieraj dokumenty UPO. ---- - -import { Aside, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; - -Przetwarzanie w KSeF jest asynchroniczne. Przechowuj referencje sesji i -referencje faktur, żeby sprawdzać status i pobierać UPO po zakończeniu procesu -wysyłki. - -## Status sesji online - -```python -with auth.online_session(form_code=FormSchema.FA3) as session: - result = session.send_invoice(invoice_xml=xml_bytes) - invoice_status = session.wait_for_invoice_ready( - invoice_reference_number=result.reference_number, - timeout=120.0, - ) - upo_xml = session.get_invoice_upo_by_reference( - invoice_reference_number=result.reference_number, - ) -``` - -## Status batch - -```python -state = auth.batch.submit_prepared_batch(prepared_batch=prepared) -final_status = auth.batch.wait_for_completion(session=state, timeout=300.0) - -accepted = auth.batch.list_invoices(session=state) -failed = auth.batch.list_failed_invoices(session=state) -``` - -Jeśli KSeF zwróci referencję UPO dla batcha, użyj jej do pobrania dokumentu: - -```python -upo_xml = auth.batch.get_upo( - session=state, - upo_reference_number="upo-reference-from-status", -) -``` - -## Przeglądanie historycznych sesji - -Użyj `auth.invoice_sessions`, gdy musisz znaleźć sesje po restarcie procesu. - - - - -```python -page = auth.invoice_sessions.query( - session_type="online", - statuses=["processing", "completed"], -) -``` - - - - -```python -for page in auth.invoice_sessions.all(session_type="batch"): - for item in page.sessions: - print(item.reference_number, item.status) -``` - - - - - - -## Zalecany przepływ - - - -1. Zapisz referencje sesji i faktur zwrócone przez KSeF. - -2. Odpytuj status sesji albo faktury do osiągnięcia stanu końcowego. - -3. Zapisz szczegóły faktur przyjętych i odrzuconych. - -4. Pobierz i zapisz odpowiedni dokument UPO. - - - -## Referencja - -- [Wysyłanie faktur](sending-invoices.mdx) -- [API statusu i UPO](../reference/api/status-upo.md) -- [API aktywnych sesji](../reference/api/active-sessions.md) diff --git a/docs/pl/workflows/test-data.mdx b/docs/pl/workflows/test-data.mdx deleted file mode 100644 index a901549..0000000 --- a/docs/pl/workflows/test-data.mdx +++ /dev/null @@ -1,84 +0,0 @@ ---- -title: Dane TEST -description: Twórz sandboxowe podmioty, osoby, uprawnienia, flagi załączników i blokady kontekstów w TEST. ---- - -import { Aside, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; - -Używaj `client.testdata` tylko w `Environment.TEST`. Te pomocniki modyfikują -dane sandboxa, dzięki czemu testy i demo mogą tworzyć znane konteksty. - -## Twórz i sprzątaj ręcznie - -```python -client.testdata.create_subject( - nip="5261040828", - subject_type="vat_group", - description="Sandbox company", -) - -client.testdata.enable_attachments(nip="5261040828") - -client.testdata.delete_subject(nip="5261040828") -``` - -## Użyj temporal cleanup - -Pomocnik temporal zapisuje mutacje i próbuje je posprzątać przy wyjściu z bloku. - - - - -```python -with client.testdata.temporal() as data: - data.create_subject( - nip="5261040828", - subject_type="vat_group", - description="Integration test subject", - ) - data.enable_attachments(nip="5261040828") -``` - - - - -```python -from ksef2.domain.models import Identifier, Permission - -with client.testdata.temporal() as data: - data.grant_permissions( - permissions=[Permission(type="invoice_read", description="Read invoices")], - grant_to=Identifier(type="nip", value="1111111111"), - in_context_of=Identifier(type="nip", value="5261040828"), - ) -``` - - - - - - -## Zalecany przepływ - - - -1. Utwórz podmioty, osoby, uprawnienia albo flagi załączników wymagane przez - test. - -2. Użyj `temporal()` dla fixture'ów, które powinny zostać posprzątane - automatycznie. - -3. Użyj bezpośrednich metod create/delete, gdy setup jest współdzielony przez - wiele uruchomień testów. - -4. Trzymaj wygenerowane identyfikatory w konfiguracji testów, nie produkcji. - - - -## Referencja - -- [Konfiguracja klienta](client-setup.mdx) -- [API danych TEST](../reference/api/testdata.md) diff --git a/docs/pl/workflows/tokens.mdx b/docs/pl/workflows/tokens.mdx deleted file mode 100644 index 0bd64e2..0000000 --- a/docs/pl/workflows/tokens.mdx +++ /dev/null @@ -1,78 +0,0 @@ ---- -title: Tokeny -description: Generuj, listuj, sprawdzaj i cofaj tokeny KSeF przez ksef2. ---- - -import { Aside, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; - -Użyj `auth.tokens`, gdy uwierzytelniony kontekst ma tworzyć albo wycofywać -tokeny KSeF dla automatyzacji. Wartości wygenerowanych tokenów traktuj jak -sekrety. - -## Wygeneruj token - -```python -token = auth.tokens.generate( - permissions=["invoice_read"], - description="nightly export", - timeout=60.0, -) - -print(token.reference_number) -print(token.token) -``` - - - -## Listuj i sprawdzaj tokeny - - - - -```python -for page in auth.tokens.list_all(): - for item in page.tokens: - print(item.reference_number, item.status, item.description) -``` - - - - -```python -status = auth.tokens.status(reference_number="token-reference") -print(status.status) -``` - - - - -## Cofnij token - -```python -auth.tokens.revoke(reference_number="token-reference") -``` - -## Zalecany przepływ - - - -1. Wybierz najmniejszy zestaw uprawnień wymagany przez automatyzację. - -2. Wygeneruj token w kontekście uwierzytelnionym właściciela. - -3. Zapisz wartość tokena w magazynie sekretów, a referencję w metadanych. - -4. Listuj albo sprawdzaj referencje podczas audytów. - -5. Cofaj nieużywane albo naruszone tokeny. - - - -## Referencja - -- [Uwierzytelnianie](authentication.mdx) -- [API tokenów](../reference/api/tokens.md) diff --git a/docs/pl/workflows/xades.mdx b/docs/pl/workflows/xades.mdx deleted file mode 100644 index 36761e1..0000000 --- a/docs/pl/workflows/xades.mdx +++ /dev/null @@ -1,104 +0,0 @@ ---- -title: Pomocniki XAdES -description: Ładuj certyfikaty i klucze prywatne, generuj certyfikaty TEST i podpisuj XML dla uwierzytelniania KSeF. ---- - -import { Aside, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; - -Użyj pomocników XAdES, gdy uwierzytelniasz się materiałem certyfikatu albo -musisz lokalnie podpisać żądanie tokena uwierzytelniającego. Metody -uwierzytelniania wyższego poziomu wywołują te pomocniki dla typowych ścieżek. - -## Załaduj materiał certyfikatu - - - - -```python -import os - -from ksef2.xades import load_certificate_from_pem, load_private_key_from_pem - -password = os.environ.get("KSEF2_KEY_PASSWORD") -cert = load_certificate_from_pem("company.pem") -private_key = load_private_key_from_pem( - "company.key", - password=password.encode() if password else None, -) -``` - - - - -```python -import os - -from ksef2.xades import load_certificate_and_key_from_p12 - -password = os.environ.get("KSEF2_P12_PASSWORD") -cert, private_key = load_certificate_and_key_from_p12( - "company.p12", - password=password.encode() if password else None, -) -``` - - - - -## Uwierzytelnij przez XAdES - -```python -auth = client.authentication.with_xades( - nip="5261040828", - cert=cert, - private_key=private_key, -) -``` - -## Generuj certyfikaty TEST - -```python -from ksef2.xades import generate_test_certificate - -cert, private_key = generate_test_certificate(nip="5261040828") -``` - -Dla osobistych tożsamości TEST użyj `generate_personal_test_certificate()`. - -## Podpisz XML bezpośrednio - -```python -from ksef2.xades import build_auth_token_request_xml, sign_xades - -xml = build_auth_token_request_xml( - challenge="challenge-from-ksef", - nip="5261040828", -) -signed_xml = sign_xades(xml, cert, private_key) -``` - - - -## Zalecany przepływ - - - -1. Załaduj materiał certyfikatu z PEM albo PKCS#12. - -2. Trzymaj hasła kluczy prywatnych w zmiennych środowiskowych. - -3. Uwierzytelniaj się przez `with_xades()`, gdy to możliwe. - -4. Używaj bezpośrednich pomocników podpisu tylko do niskopoziomowej integracji. - - - -## Referencja - -- [Uwierzytelnianie](authentication.mdx) -- [Certyfikaty](certificates.mdx) -- [API XAdES](../reference/api/xades.md) From 036228cf0b475f146b4e7bdc6391e84f8b8752d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20Podsiad=C5=82y?= Date: Mon, 29 Jun 2026 02:27:32 +0200 Subject: [PATCH 10/11] docs: update CLI invoice examples --- README.md | 2 +- README.pl.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 58d546c..eaebf43 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ ksef2 profile create prod-token \ ksef2 profile use prod-token # example usage of the cli -ksef2 --profile prod-token invoices metadata \ +ksef2 --profile prod-token invoices list \ --role seller \ --date-from 2026-01-01T00:00:00Z ``` diff --git a/README.pl.md b/README.pl.md index b3c31e4..f1af3c2 100644 --- a/README.pl.md +++ b/README.pl.md @@ -140,7 +140,7 @@ ksef2 profile create prod-token \ ksef2 profile use prod-token # przykładowe użycie CLI -ksef2 --profile prod-token invoices metadata \ +ksef2 --profile prod-token invoices list \ --role seller \ --date-from 2026-01-01T00:00:00Z ``` From 25c0a789932c108d484e14f47cca56bf278388d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20Podsiad=C5=82y?= Date: Mon, 29 Jun 2026 02:37:11 +0200 Subject: [PATCH 11/11] fix: repair clean release checks --- justfile | 8 ++++---- src/ksef2/services/async_batch.py | 3 ++- src/ksef2/services/batch.py | 3 ++- tests/unit/clients/test_async_base.py | 13 ++++--------- tests/unit/clients/test_base.py | 13 ++++--------- 5 files changed, 16 insertions(+), 24 deletions(-) diff --git a/justfile b/justfile index ffedd9e..8cede36 100644 --- a/justfile +++ b/justfile @@ -46,14 +46,14 @@ format-check: uv run ruff format --check src/ tests/ scripts/gen_sync.py gen-sync: - uv run python scripts/gen_sync.py + uv run --group codegen python scripts/gen_sync.py check-gen-sync: - uv run python scripts/gen_sync.py --check + uv run --group codegen python scripts/gen_sync.py --check typecheck: - uv run --extra runtime-checks basedpyright --level error - uv run --extra runtime-checks basedpyright scripts/gen_sync.py + GITHUB_ACTIONS= uv run --extra runtime-checks basedpyright --level error + GITHUB_ACTIONS= uv run --extra runtime-checks --group codegen basedpyright scripts/gen_sync.py sync-ksef-api-version: diff --git a/src/ksef2/services/async_batch.py b/src/ksef2/services/async_batch.py index 211100c..6f705b4 100644 --- a/src/ksef2/services/async_batch.py +++ b/src/ksef2/services/async_batch.py @@ -3,7 +3,7 @@ import asyncio from collections.abc import Awaitable, Callable, Iterable from pathlib import Path -from typing import Protocol, final +from typing import Protocol, final, runtime_checkable from ksef2.clients._async_session import _AwaitableSession from ksef2.clients.async_batch import AsyncBatchSessionClient @@ -32,6 +32,7 @@ ) +@runtime_checkable class AsyncBatchSessionOpener(Protocol): def __call__( self, diff --git a/src/ksef2/services/batch.py b/src/ksef2/services/batch.py index 84c2913..9e46c54 100644 --- a/src/ksef2/services/batch.py +++ b/src/ksef2/services/batch.py @@ -5,7 +5,7 @@ from collections.abc import Callable, Iterable from pathlib import Path -from typing import Protocol, final +from typing import Protocol, final, runtime_checkable from ksef2.clients.batch import BatchSessionClient from ksef2.core import exceptions @@ -33,6 +33,7 @@ ) +@runtime_checkable class BatchSessionOpener(Protocol): def __call__( self, diff --git a/tests/unit/clients/test_async_base.py b/tests/unit/clients/test_async_base.py index 85cc74d..20455a6 100644 --- a/tests/unit/clients/test_async_base.py +++ b/tests/unit/clients/test_async_base.py @@ -8,6 +8,7 @@ from polyfactory import BaseFactory from ksef2.clients.async_auth import AsyncAuthClient +from ksef2.clients.async_authenticated import AsyncAuthenticatedClient from ksef2.clients.async_base import AsyncClient from ksef2.clients.async_encryption import AsyncEncryptionClient from ksef2.clients.async_peppol import AsyncPeppolClient @@ -167,15 +168,11 @@ def test_authentication_accessor_uses_custom_certificate_store( _, kwargs = auth_client_cls.call_args assert kwargs["certificate_store"] is store - @patch("ksef2.clients.async_base.AsyncAuthClient") def test_authenticated_deprecated_wrapper_delegates_to_auth_branch( self, - auth_client_cls: MagicMock, domain_auth_tokens: BaseFactory[AuthTokens], ) -> None: store = CustomCertificateStore() - auth_branch = MagicMock() - auth_client_cls.return_value = auth_branch auth_tokens = domain_auth_tokens.build() client = AsyncClient( environment=Environment.TEST, @@ -185,11 +182,9 @@ def test_authenticated_deprecated_wrapper_delegates_to_auth_branch( try: with pytest.deprecated_call(match="AsyncClient.authenticated"): - _ = client.authenticated(auth_tokens) + authenticated = client.authenticated(auth_tokens) finally: asyncio.run(client.aclose()) - _, kwargs = auth_client_cls.call_args - assert kwargs["certificate_store"] is store - state = auth_branch.resume.call_args.args[0] - assert state.to_tokens() == auth_tokens + assert isinstance(authenticated, AsyncAuthenticatedClient) + assert authenticated.resume_state().to_tokens() == auth_tokens diff --git a/tests/unit/clients/test_base.py b/tests/unit/clients/test_base.py index 4b1a3e8..2dc76e5 100644 --- a/tests/unit/clients/test_base.py +++ b/tests/unit/clients/test_base.py @@ -6,6 +6,7 @@ import pytest from polyfactory import BaseFactory +from ksef2.clients.authenticated import AuthenticatedClient from ksef2.clients.base import Client from ksef2.clients.testdata import TestDataClient as KSeFTestDataClient from ksef2.config import Environment, TimeoutConfig, TransportConfig @@ -152,15 +153,11 @@ def test_authentication_accessor_uses_custom_certificate_store( _, kwargs = auth_client_cls.call_args assert kwargs["certificate_store"] is store - @patch("ksef2.clients.base.AuthClient") def test_authenticated_deprecated_wrapper_delegates_to_auth_branch( self, - auth_client_cls: MagicMock, domain_auth_tokens: BaseFactory[AuthTokens], ) -> None: store = CustomCertificateStore() - auth_branch = MagicMock() - auth_client_cls.return_value = auth_branch auth_tokens = domain_auth_tokens.build() client = Client( environment=Environment.TEST, @@ -169,9 +166,7 @@ def test_authenticated_deprecated_wrapper_delegates_to_auth_branch( ) with pytest.deprecated_call(match="Client.authenticated"): - _ = client.authenticated(auth_tokens) + authenticated = client.authenticated(auth_tokens) - _, kwargs = auth_client_cls.call_args - assert kwargs["certificate_store"] is store - state = auth_branch.resume.call_args.args[0] - assert state.to_tokens() == auth_tokens + assert isinstance(authenticated, AuthenticatedClient) + assert authenticated.resume_state().to_tokens() == auth_tokens