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.
- nexterp_declarations/XSD_BUNDLING.md +71 -0
- nexterp_declarations/__init__.py +12 -0
- nexterp_declarations/_version.py +24 -0
- nexterp_declarations/base/__init__.py +78 -0
- nexterp_declarations/base/_client.py +112 -0
- nexterp_declarations/base/common_moves.py +158 -0
- nexterp_declarations/base/envelope_v2.py +81 -0
- nexterp_declarations/base/exceptions.py +13 -0
- nexterp_declarations/base/schemas.py +135 -0
- nexterp_declarations/d100/__init__.py +37 -0
- nexterp_declarations/d100/_dispatcher.py +83 -0
- nexterp_declarations/d100/schema.py +167 -0
- nexterp_declarations/d101/__init__.py +30 -0
- nexterp_declarations/d101/_dispatcher.py +96 -0
- nexterp_declarations/d101/schema.py +127 -0
- nexterp_declarations/d120/__init__.py +31 -0
- nexterp_declarations/d120/_dispatcher.py +78 -0
- nexterp_declarations/d120/schema.py +129 -0
- nexterp_declarations/d300/__init__.py +37 -0
- nexterp_declarations/d300/_dispatcher.py +82 -0
- nexterp_declarations/d300/schema.py +93 -0
- nexterp_declarations/d390/__init__.py +34 -0
- nexterp_declarations/d390/_dispatcher.py +81 -0
- nexterp_declarations/d390/schema.py +71 -0
- nexterp_declarations/d394/__init__.py +31 -0
- nexterp_declarations/d394/_dispatcher.py +91 -0
- nexterp_declarations/d394/schema.py +234 -0
- nexterp_declarations/d406/__init__.py +100 -0
- nexterp_declarations/d406/_dispatcher.py +75 -0
- nexterp_declarations/d406/schema.py +612 -0
- nexterp_declarations/intrastat/__init__.py +39 -0
- nexterp_declarations/intrastat/_dispatcher.py +71 -0
- nexterp_declarations/intrastat/schema.py +147 -0
- nexterp_declarations-0.2.0.dist-info/METADATA +67 -0
- nexterp_declarations-0.2.0.dist-info/RECORD +37 -0
- nexterp_declarations-0.2.0.dist-info/WHEEL +5 -0
- 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.)."""
|