nexterp-declarations 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. nexterp_declarations/XSD_BUNDLING.md +71 -0
  2. nexterp_declarations/__init__.py +12 -0
  3. nexterp_declarations/_version.py +24 -0
  4. nexterp_declarations/base/__init__.py +78 -0
  5. nexterp_declarations/base/_client.py +112 -0
  6. nexterp_declarations/base/common_moves.py +158 -0
  7. nexterp_declarations/base/envelope_v2.py +81 -0
  8. nexterp_declarations/base/exceptions.py +13 -0
  9. nexterp_declarations/base/schemas.py +135 -0
  10. nexterp_declarations/d100/__init__.py +37 -0
  11. nexterp_declarations/d100/_dispatcher.py +83 -0
  12. nexterp_declarations/d100/schema.py +167 -0
  13. nexterp_declarations/d101/__init__.py +30 -0
  14. nexterp_declarations/d101/_dispatcher.py +96 -0
  15. nexterp_declarations/d101/schema.py +127 -0
  16. nexterp_declarations/d120/__init__.py +31 -0
  17. nexterp_declarations/d120/_dispatcher.py +78 -0
  18. nexterp_declarations/d120/schema.py +129 -0
  19. nexterp_declarations/d300/__init__.py +37 -0
  20. nexterp_declarations/d300/_dispatcher.py +82 -0
  21. nexterp_declarations/d300/schema.py +93 -0
  22. nexterp_declarations/d390/__init__.py +34 -0
  23. nexterp_declarations/d390/_dispatcher.py +81 -0
  24. nexterp_declarations/d390/schema.py +71 -0
  25. nexterp_declarations/d394/__init__.py +31 -0
  26. nexterp_declarations/d394/_dispatcher.py +91 -0
  27. nexterp_declarations/d394/schema.py +234 -0
  28. nexterp_declarations/d406/__init__.py +100 -0
  29. nexterp_declarations/d406/_dispatcher.py +75 -0
  30. nexterp_declarations/d406/schema.py +612 -0
  31. nexterp_declarations/intrastat/__init__.py +39 -0
  32. nexterp_declarations/intrastat/_dispatcher.py +71 -0
  33. nexterp_declarations/intrastat/schema.py +147 -0
  34. nexterp_declarations-0.2.0.dist-info/METADATA +67 -0
  35. nexterp_declarations-0.2.0.dist-info/RECORD +37 -0
  36. nexterp_declarations-0.2.0.dist-info/WHEEL +5 -0
  37. nexterp_declarations-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,71 @@
1
+ # XSD bundling per declaration version
2
+
3
+ Each version subdir (`<decl>/<vN>/`) can carry a `schema.xsd` next to its
4
+ `generator.py`. When present, `gen.validate_xsd(xml_bytes)` validates the
5
+ emitted XML against it; when missing, `validate_xsd` returns a single
6
+ ``warning`` issue telling the caller exactly where to drop the file.
7
+
8
+ ## Currently bundled (from `l10n-romania-enterprise`)
9
+
10
+ | Declaration | Version | XSD file | Source |
11
+ |---|---|---|---|
12
+ | D101 | v10 | `d101/v10/schema.xsd` | `l10n_ro_declaration_D101/data/xsd/d101_20250214.xsd` |
13
+ | D120 | v40 | `d120/v40/schema.xsd` | `l10n_ro_declaration_D120/static/xsd/d120_v3_24032020.xsd` |
14
+ | D120 | v50 | `d120/v50/schema.xsd` | `l10n_ro_declaration_D120/static/xsd/d120_13052025.xsd` |
15
+ | D390 | v3 | `d390/v3/schema.xsd` | `l10n_ro_declaration_D390/static/xsd/d390_20200323.xsd` |
16
+ | D390 | v41 | `d390/v41/schema.xsd` | `l10n_ro_declaration_D390/static/xsd/d390_12022021.xsd` |
17
+
18
+ ## Pending (URL-only references — ANAF serves them off `static.anaf.ro`)
19
+
20
+ For each, drop the downloaded `.xsd` into the matching directory below
21
+ and `validate_xsd` will pick it up automatically (resolved via
22
+ `importlib.resources` in the XSD mixin).
23
+
24
+ | Declaration | Version | Path inside the package | Source URL |
25
+ |---|---|---|---|
26
+ | D100 | v2 | `d100/v2/schema.xsd` | <https://static.anaf.ro/static/10/Anaf/Declaratii_R/AplicatiiDec/d100_24022022.xsd> |
27
+ | D710 | v1 | `d100/v2/d710.xsd` † | <https://static.anaf.ro/static/10/Anaf/Declaratii_R/AplicatiiDec/d710_20012025.xsd> |
28
+ | D300 | v6 | `d300/v6/schema.xsd` | <https://static.anaf.ro/static/10/Anaf/Declaratii_R/AplicatiiDec/d300_06092019.xsd> |
29
+ | D300 | v7 | `d300/v7/schema.xsd` | (ANAF release — confirm URL on publication notice) |
30
+ | D300 | v9 | `d300/v9/schema.xsd` | (ANAF release — confirm URL) |
31
+ | D300 | v10 | `d300/v10/schema.xsd` | (ANAF release — confirm URL) |
32
+ | D300 | v11 | `d300/v11/schema.xsd` | (ANAF release — confirm URL) |
33
+ | D300 | v12 | `d300/v12/schema.xsd` | (ANAF release — confirm URL) |
34
+ | D394 | v3 | `d394/v3/schema.xsd` | <https://static.anaf.ro/static/10/Anaf/Declaratii_R/AplicatiiDec/d394_20200925.xsd> |
35
+ | D394 | v4 | `d394/v4/schema.xsd` | <https://static.anaf.ro/static/10/Anaf/Declaratii_R/AplicatiiDec/d394_20220510.xsd> |
36
+ | D394 | v5 | `d394/v5/schema.xsd` | (ANAF release — confirm URL) |
37
+ | D406 | v1 | `d406/v1/Romanian_SAF-T_Financial_Schema_v_2.4.9.xsd` ‡ | <https://static.anaf.ro/static/10/Anaf/Declaratii_R/AplicatiiDec/Romanian_SAF-T_Financial_Schema_v_2.4.9.xsd> |
38
+ | Intrastat | v1 | `intrastat/v1/schema.xsd` | INS — see Intrastat handbook |
39
+
40
+ † D710 shares the wizard with D100 but has a distinct XSD. The current
41
+ `D100GeneratorV2._envelope()` swaps the `xsi:schemaLocation` filename via
42
+ `_DECL_FORMAT`; bundle D710's XSD under a non-default filename and override
43
+ `_xsd_resource` on the rectifying branch (Wave 2).
44
+
45
+ ‡ D406 carries the SAF-T XSD filename verbatim in its `xsi:schemaLocation`
46
+ attribute, so the file under `d406/v1/` must keep that exact name. The
47
+ `XSDValidatableMixin` reads its `_xsd_resource` class attribute as the
48
+ filename — D406 already overrides it to the SAF-T filename.
49
+
50
+ ## How to refresh after ANAF publishes a new version
51
+
52
+ The duk_validation watcher in [`validator/app/watcher/`](../../validator/app/watcher/)
53
+ detects the URL change and writes a diff JSON (see
54
+ [`validator/app/watcher/diff_publisher.py`](../../validator/app/watcher/diff_publisher.py)).
55
+ The CI bot at
56
+ [`.github/workflows/auto-version-skeleton.yml`](../../.github/workflows/auto-version-skeleton.yml)
57
+ runs `tools/gen_version_skeleton.py` against the diff and opens a PR with
58
+ the skeleton class. **The PR does NOT bundle the new XSD** — that's a
59
+ manual step on every release.
60
+
61
+ Workflow when reviewing such a PR:
62
+
63
+ 1. Download the XSD from the URL in the diff JSON's `source_url`.
64
+ 2. Drop it as `<decl>/<vN>/schema.xsd` (or whatever the dispatcher's
65
+ `_xsd_resource` resolves to).
66
+ 3. Run `pytest tests/` — the smoke test for the new version will start
67
+ failing if your renderer produces output the bundled XSD rejects.
68
+ 4. Iterate on the generator until the XSD validates.
69
+
70
+ The XSD is part of the wheel via `MANIFEST.in` (`recursive-include src *.xsd`),
71
+ so once committed it ships in the next pip release.
@@ -0,0 +1,12 @@
1
+ """nexterp-declarations: Romanian ANAF declarations (schemas + generators)."""
2
+
3
+ from ._version import __version__
4
+ from .base._client import GenerationError, LicenseError, configure_license, remote_generate
5
+
6
+ __all__ = [
7
+ "__version__",
8
+ "configure_license",
9
+ "remote_generate",
10
+ "GenerationError",
11
+ "LicenseError",
12
+ ]
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.2.0'
22
+ __version_tuple__ = version_tuple = (0, 2, 0)
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,78 @@
1
+ """Shared schemas + utilities used by every declaration.
2
+
3
+ Split by who needs them:
4
+
5
+ * **Data models** (CompanyV2, MovesData, the schemas, exceptions) are the
6
+ public input contract -- imported eagerly; clients need them to build a
7
+ declaration input. They ship in the distributed wheel.
8
+ * **Generation helpers** (``coa`` classifier, ``xml_render``,
9
+ ``xsd_validator``) are server-side logic -- imported lazily via
10
+ ``__getattr__`` so importing :mod:`nexterp_declarations.base` (e.g. to get
11
+ ``CompanyV2``) never pulls them in. They are excluded from the distributed
12
+ wheel and live only where generation runs (the duk service, from source).
13
+ """
14
+
15
+ # Data models -- always available (the public input contract).
16
+ from .common_moves import Move, MoveLine, MovesData, Partner, VatRegime
17
+ from .envelope_v2 import CompanyV2, PeriodV2
18
+ from .exceptions import DeclarationError, GenerationError, ValidationError
19
+ from .schemas import (
20
+ Address,
21
+ Bank,
22
+ Company,
23
+ DeclarationKind,
24
+ MonetaryAmount,
25
+ Period,
26
+ Signer,
27
+ ValidationIssue,
28
+ )
29
+
30
+ # Generation helpers -- resolved lazily (excluded from the client wheel).
31
+ _LAZY = {
32
+ "MovementKind": ("coa", "MovementKind"),
33
+ "classify": ("coa", "classify"),
34
+ "is_intracom": ("coa", "is_intracom"),
35
+ "render_document": ("xml_render", "render_document"),
36
+ "render_section": ("xml_render", "render_section"),
37
+ "value_to_xml_attr": ("xml_render", "value_to_xml_attr"),
38
+ "validate_xml": ("xsd_validator", "validate_xml"),
39
+ }
40
+
41
+
42
+ def __getattr__(name):
43
+ target = _LAZY.get(name)
44
+ if target is None:
45
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
46
+ import importlib
47
+
48
+ mod = importlib.import_module(f".{target[0]}", __name__)
49
+ return getattr(mod, target[1])
50
+
51
+
52
+ __all__ = [
53
+ "Address",
54
+ "Bank",
55
+ "Company",
56
+ "CompanyV2",
57
+ "DeclarationError",
58
+ "DeclarationKind",
59
+ "GenerationError",
60
+ "MonetaryAmount",
61
+ "Move",
62
+ "MoveLine",
63
+ "MovementKind",
64
+ "MovesData",
65
+ "Partner",
66
+ "Period",
67
+ "PeriodV2",
68
+ "Signer",
69
+ "ValidationError",
70
+ "ValidationIssue",
71
+ "VatRegime",
72
+ "classify",
73
+ "is_intracom",
74
+ "render_document",
75
+ "render_section",
76
+ "validate_xml",
77
+ "value_to_xml_attr",
78
+ ]
@@ -0,0 +1,112 @@
1
+ """Thin client -- delegate generation + validation to the duk service.
2
+
3
+ In the distributed (customer) install the package ships only the input
4
+ *schemas* and this client; the generation logic lives on
5
+ ``duk.nexterp.ro``. The host (Odoo) calls :func:`configure_license` once with
6
+ the signed license token it obtained from nexterp.ro and the duk URL, then
7
+ :func:`remote_generate` POSTs the composed input to ``/generate`` and returns
8
+ the XML. The token is what authorises the call (verified offline on duk); the
9
+ SAF-T-sized payloads are generated server-side without the logic ever leaving
10
+ duk.
11
+
12
+ Nothing here is secret -- it is an HTTP client over the public input schemas.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ import json
18
+ import urllib.error
19
+ import urllib.request
20
+
21
+ _TOKEN: str | None = None
22
+ _SERVER_URL: str | None = None
23
+
24
+
25
+ class LicenseError(RuntimeError):
26
+ """The duk service rejected the license token (missing/expired/uncovered)."""
27
+
28
+
29
+ class GenerationError(RuntimeError):
30
+ """The duk service failed to generate or validate the declaration."""
31
+
32
+
33
+ def configure_license(token: str | None, server_url: str | None) -> None:
34
+ """Register the signed license token + duk base URL for :func:`remote_generate`."""
35
+ global _TOKEN, _SERVER_URL
36
+ _TOKEN = token
37
+ _SERVER_URL = server_url.rstrip("/") if server_url else None
38
+
39
+
40
+ def _decompose(inp):
41
+ """Split a ``<Decl>Input`` into the duk request parts.
42
+
43
+ ``header``/``company``/``period`` map verbatim; every other Input field
44
+ (``data``, ``lines``, ``arrivals``/``dispatches``, ``serie_facturi`` ...)
45
+ goes into ``data`` keyed by its field name -- matching the duk
46
+ auto-registrar's wire format.
47
+ """
48
+ full = inp.model_dump(mode="json")
49
+ header = full.pop("header", {})
50
+ company = full.pop("company")
51
+ period = full.pop("period")
52
+ return header, company, period, full
53
+
54
+
55
+ def remote_generate(
56
+ declaration: str,
57
+ inp,
58
+ *,
59
+ validate_xsd: bool = True,
60
+ validate_duk: bool = True,
61
+ return_pdf: bool = False,
62
+ user_email: str = "",
63
+ timeout: int = 300,
64
+ ) -> bytes:
65
+ """POST ``inp`` to duk ``/generate`` and return the XML bytes.
66
+
67
+ Raises :class:`LicenseError` on 401/403 (bad/expired/uncovered token) and
68
+ :class:`GenerationError` on validation/generation failure.
69
+ """
70
+ if not _SERVER_URL:
71
+ raise LicenseError(
72
+ "nexterp-declarations: call configure_license(token, server_url) first"
73
+ )
74
+ header, company, period, data = _decompose(inp)
75
+ payload = {
76
+ "declaration": declaration,
77
+ "company": company,
78
+ "period": period,
79
+ "header": header,
80
+ "data": data,
81
+ "token": _TOKEN or "",
82
+ "options": {
83
+ "validate_xsd": validate_xsd,
84
+ "validate_duk": validate_duk,
85
+ "return_xml": True,
86
+ "return_pdf": return_pdf,
87
+ },
88
+ "user_email": user_email,
89
+ }
90
+ req = urllib.request.Request(
91
+ f"{_SERVER_URL}/generate",
92
+ data=json.dumps(payload).encode(),
93
+ headers={"Content-Type": "application/json"},
94
+ )
95
+ try:
96
+ with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 - fixed scheme
97
+ result = json.loads(resp.read())
98
+ except urllib.error.HTTPError as exc:
99
+ body = exc.read().decode(errors="replace")
100
+ if exc.code in (401, 403):
101
+ raise LicenseError(f"license rejected by duk ({exc.code}): {body}") from exc
102
+ raise GenerationError(f"duk /generate failed ({exc.code}): {body}") from exc
103
+ except urllib.error.URLError as exc:
104
+ raise GenerationError(f"duk /generate unreachable: {exc}") from exc
105
+
106
+ if not result.get("ok"):
107
+ msgs = "; ".join(i.get("message", "") for i in (result.get("issues") or []))
108
+ raise GenerationError(msgs or "duk reported the declaration invalid")
109
+ xml_b64 = result.get("xml_base64")
110
+ if not xml_b64:
111
+ raise GenerationError("duk returned no XML")
112
+ return base64.b64decode(xml_b64)
@@ -0,0 +1,158 @@
1
+ """Shared accounting payload -- used by every D-series declaration.
2
+
3
+ Each declaration has its own ``data`` shape *wrapper* (D300Data,
4
+ D390Data, D394Data, ...), but the underlying ``Partner``, ``Move``,
5
+ and ``MoveLine`` records are identical: they describe the same raw
6
+ accounting facts.
7
+
8
+ A line carries the **objective accounting properties** of the
9
+ transaction. The service applies declaration-specific rules (account
10
+ code -> sale/purchase/goods/services, partner country -> intra-EU /
11
+ export, etc.) to figure out which ANAF row the line feeds.
12
+
13
+ VAT regime nuances that the four core flags (``vat_rate``,
14
+ ``vat_on_payment``, ``exempted``, ``deductibility``) can't express
15
+ travel via three additional booleans/enums:
16
+
17
+ * ``reverse_charge`` -- domestic reverse-charge cases (cereals, scrap,
18
+ electronics, etc.); the line still has a positive ``vat_rate`` but
19
+ the *seller* doesn't collect VAT.
20
+ * ``exempt_with_deduction_right`` -- only meaningful when
21
+ ``exempted=True``; distinguishes exports / intracom from medical /
22
+ educational exemptions.
23
+ * ``regime`` -- special regimes: standard, margin scheme, second-hand,
24
+ travel agency, special.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ from datetime import date
29
+ from typing import Literal
30
+
31
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
32
+
33
+ _VALID_VAT_RATES = {0, 5, 9, 11, 19, 21, 24}
34
+ _VALID_DEDUCTIBILITY = {0, 50, 100}
35
+
36
+ VatRegime = Literal["standard", "margin", "second_hand", "travel", "special"]
37
+
38
+
39
+ class Partner(BaseModel):
40
+ """Counterparty directory entry."""
41
+
42
+ model_config = ConfigDict(extra="forbid")
43
+
44
+ cui: str = Field(min_length=1, max_length=50, description="Partner VAT number, with country prefix.")
45
+ country: str = Field(min_length=2, max_length=2, description="ISO-3166-1 alpha-2 country code (uppercase).")
46
+ name: str = Field(min_length=1, max_length=200)
47
+
48
+ @field_validator("country", mode="before")
49
+ @classmethod
50
+ def _upper_country(cls, v: str) -> str:
51
+ if not isinstance(v, str):
52
+ raise TypeError("country must be a string")
53
+ return v.strip().upper()
54
+
55
+
56
+ class MoveLine(BaseModel):
57
+ """One accounting line.
58
+
59
+ ``deductibility`` is purchase-side only (``null`` on sale lines).
60
+ ``exempt_with_deduction_right`` is meaningful only when ``exempted=True``.
61
+ ``reverse_charge`` is for domestic reverse-charge regimes (intracom
62
+ operations are derived from the partner's country, not this flag).
63
+ """
64
+
65
+ model_config = ConfigDict(extra="forbid")
66
+
67
+ account_code: str = Field(min_length=1, max_length=20)
68
+ tax_base: float = Field(description="Signed base amount (negative for refunds/returns).")
69
+ vat_amount: float = Field(default=0.0)
70
+ vat_rate: int = Field(description="VAT rate as integer percentage.")
71
+ vat_on_payment: bool = Field(default=False)
72
+ exempted: bool = Field(default=False)
73
+ deductibility: int | None = Field(default=None)
74
+ reverse_charge: bool = Field(default=False)
75
+ exempt_with_deduction_right: bool = Field(default=False)
76
+ regime: VatRegime = Field(default="standard")
77
+ correction: bool = Field(
78
+ default=False,
79
+ description=(
80
+ "True when this line is a *regularization* of an earlier-period "
81
+ "invoice (Odoo's ``l10n_ro_correction`` flag). D300 routes "
82
+ "corrections to dedicated rows: R2 for intracom sale fixes, "
83
+ "R11 for domestic sale fixes, R14 for exempt-no-deduction fixes."
84
+ ),
85
+ )
86
+ product_anaf_code: str | None = Field(default=None)
87
+ # Canonical ANAF operation type from the Odoo localization
88
+ # (``l10n_ro_operation_type``: L/A/LS/AS/AI/V/C/N). When set it is
89
+ # authoritative; consumers fall back to a flag-based heuristic only when
90
+ # it is absent.
91
+ oper_type: str | None = Field(default=None)
92
+
93
+ @field_validator("vat_rate")
94
+ @classmethod
95
+ def _validate_rate(cls, v: int) -> int:
96
+ if v not in _VALID_VAT_RATES:
97
+ raise ValueError(
98
+ f"vat_rate must be one of {sorted(_VALID_VAT_RATES)}, got {v!r}"
99
+ )
100
+ return v
101
+
102
+ @field_validator("deductibility")
103
+ @classmethod
104
+ def _validate_ded(cls, v: int | None) -> int | None:
105
+ if v is None:
106
+ return v
107
+ if v not in _VALID_DEDUCTIBILITY:
108
+ raise ValueError(
109
+ f"deductibility must be one of {sorted(_VALID_DEDUCTIBILITY)} "
110
+ f"or null, got {v!r}"
111
+ )
112
+ return v
113
+
114
+
115
+ class Move(BaseModel):
116
+ """One accounting move with its lines."""
117
+
118
+ model_config = ConfigDict(extra="forbid")
119
+
120
+ id: str = Field(min_length=1, max_length=100)
121
+ date: date
122
+ partner_cui: str = Field(min_length=1, max_length=50)
123
+ lines: list[MoveLine] = Field(default_factory=list)
124
+ # Whether the partner's fiscal code is shown on the document. When False
125
+ # (e.g. a simplified receipt / bon fiscal without CUI) the operation is
126
+ # anonymous for D394 -- reported in the aggregate fields, not per-partner.
127
+ has_vat_number: bool = Field(default=True)
128
+ # Canonical ANAF tip_partener (1..4) from the Odoo localization
129
+ # (``l10n_ro_partner_type``). Authoritative when set; consumers fall back
130
+ # to a cui-based heuristic only when absent.
131
+ partner_kind: str | None = Field(default=None)
132
+ # ANAF document type (D394 ``tip_document``/``document_N``) from the Odoo
133
+ # localization (``l10n_ro_invoice_origin_d394``): 1=facturi, 2=borderouri,
134
+ # 3=file carnet comercializare, 4=contracte, 5=alte documente. Required for
135
+ # tip_partener=2 N rows; defaults to "1" (regular invoice).
136
+ doc_type: str | None = Field(default=None)
137
+
138
+
139
+ class MovesData(BaseModel):
140
+ """Common ``data`` envelope: partners directory + moves list.
141
+
142
+ Each declaration extends or wraps this with its own validations.
143
+ """
144
+
145
+ model_config = ConfigDict(extra="forbid")
146
+
147
+ partners: list[Partner] = Field(default_factory=list)
148
+ moves: list[Move] = Field(default_factory=list)
149
+
150
+ @field_validator("partners")
151
+ @classmethod
152
+ def _unique_partner_cui(cls, v: list[Partner]) -> list[Partner]:
153
+ seen: set[str] = set()
154
+ for p in v:
155
+ if p.cui in seen:
156
+ raise ValueError(f"duplicate partner CUI: {p.cui}")
157
+ seen.add(p.cui)
158
+ return v
@@ -0,0 +1,81 @@
1
+ """Alternate envelope schemas (Company, Period) shaped after the ANAF XSD.
2
+
3
+ The primary :mod:`base.schemas` exposes a structured ``Company`` (with a
4
+ nested ``Address``) and a ``Period`` whose granularity is selected by
5
+ ``month``/``quarter``/``semester``/``annual``. Those are the public
6
+ contract used by D100 and D394.
7
+
8
+ D-series declarations ported from the ``duk_validation`` service expect
9
+ a *flatter* shape that mirrors the ANAF XSD attribute names verbatim
10
+ (``cui``, ``den``, ``adresa``, ``telefon``, ``regCom``, ``caen``,
11
+ ``codTT``) and a date-range ``Period`` (``date_from``/``date_to``).
12
+ Rather than break the existing D100/D394 contracts, this module ships
13
+ those schemas alongside.
14
+
15
+ Naming policy: any field that maps to an ANAF XSD attribute keeps the
16
+ ANAF name verbatim. Service-level fields use snake_case.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from datetime import date
21
+
22
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
23
+
24
+
25
+ class CompanyV2(BaseModel):
26
+ """Identity attributes of the legal entity, named as in the ANAF XSD.
27
+
28
+ Used by D300 (and other D-series declarations ported from the
29
+ duk_validation service). The service is multi-tenant by payload:
30
+ every request carries the full company identity, no per-customer
31
+ state is held server-side.
32
+ """
33
+
34
+ model_config = ConfigDict(extra="forbid")
35
+
36
+ cui: str = Field(
37
+ description="CUI / VAT number, digits only. A leading 'RO' is "
38
+ "stripped before validation."
39
+ )
40
+ den: str = Field(min_length=1, max_length=200, description="Denumire")
41
+ adresa: str = Field(min_length=1, max_length=500)
42
+ telefon: str = Field(default="", max_length=50)
43
+ regCom: str = Field(default="", max_length=50, description="Registru Comertului")
44
+ caen: str = Field(default="", max_length=10, description="CAEN code, 4 digits")
45
+ codTT: int = Field(
46
+ ge=1, le=99, description="ANAF order code of the county (judet). 1..52."
47
+ )
48
+
49
+ @field_validator("cui", mode="before")
50
+ @classmethod
51
+ def _strip_ro(cls, v: str) -> str:
52
+ if not isinstance(v, str):
53
+ raise TypeError("cui must be a string")
54
+ v = v.strip().upper()
55
+ if v.startswith("RO"):
56
+ v = v[2:]
57
+ if not v.isdigit():
58
+ raise ValueError("cui must contain only digits (after stripping 'RO')")
59
+ return v
60
+
61
+
62
+ class PeriodV2(BaseModel):
63
+ """Date-range period used by D-series declarations.
64
+
65
+ The ANAF XSDs use ``luna``/``an`` and a ``tip_decont`` derived from
66
+ the period length; we derive both from a (``date_from``, ``date_to``)
67
+ pair so callers don't have to know the encoding.
68
+ """
69
+
70
+ model_config = ConfigDict(extra="forbid")
71
+
72
+ date_from: date
73
+ date_to: date
74
+
75
+ @property
76
+ def year(self) -> int:
77
+ return self.date_to.year
78
+
79
+ @property
80
+ def month(self) -> int:
81
+ return self.date_to.month
@@ -0,0 +1,13 @@
1
+ """Errors raised by the package. Map cleanly to UI errors on the caller side."""
2
+
3
+
4
+ class DeclarationError(Exception):
5
+ """Base for everything raised here."""
6
+
7
+
8
+ class ValidationError(DeclarationError):
9
+ """Input data does not satisfy the declaration's contract."""
10
+
11
+
12
+ class GenerationError(DeclarationError):
13
+ """Generator failed to produce the output (XML build, signing, etc.)."""