anaf-sync 0.1.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.
- anaf_sync/__init__.py +13 -0
- anaf_sync/cli.py +215 -0
- anaf_sync/config.py +248 -0
- anaf_sync/context.py +129 -0
- anaf_sync/engine.py +282 -0
- anaf_sync/scheduling.py +246 -0
- anaf_sync/state.py +139 -0
- anaf_sync/template.py +99 -0
- anaf_sync-0.1.0.dist-info/METADATA +111 -0
- anaf_sync-0.1.0.dist-info/RECORD +12 -0
- anaf_sync-0.1.0.dist-info/WHEEL +4 -0
- anaf_sync-0.1.0.dist-info/entry_points.txt +2 -0
anaf_sync/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""anaf-sync — scheduled local archiver for RO e-Factura invoices.
|
|
2
|
+
|
|
3
|
+
Lists every e-Factura message in the retention window, downloads the ones not
|
|
4
|
+
seen before, and files each artifact under a path rendered from a user-defined
|
|
5
|
+
template of invoice context variables. Built on :mod:`anafpy`; reuses the
|
|
6
|
+
credentials and token store written by ``anafpy auth login``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
|
+
|
|
13
|
+
__all__ = ["__version__"]
|
anaf_sync/cli.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""The ``anaf-sync`` command-line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Annotated
|
|
10
|
+
|
|
11
|
+
import structlog
|
|
12
|
+
import typer
|
|
13
|
+
from anafpy.exceptions import AnafError
|
|
14
|
+
|
|
15
|
+
from . import __version__
|
|
16
|
+
from .config import (
|
|
17
|
+
AuthSettings,
|
|
18
|
+
default_config_path,
|
|
19
|
+
default_state_path,
|
|
20
|
+
load_config,
|
|
21
|
+
write_default_config,
|
|
22
|
+
)
|
|
23
|
+
from .engine import run_sync
|
|
24
|
+
from .scheduling import ScheduleError
|
|
25
|
+
from .scheduling import install as schedule_install
|
|
26
|
+
from .scheduling import status as schedule_status
|
|
27
|
+
from .scheduling import uninstall as schedule_uninstall
|
|
28
|
+
from .state import SyncState
|
|
29
|
+
|
|
30
|
+
app = typer.Typer(
|
|
31
|
+
name="anaf-sync",
|
|
32
|
+
help="Archive RO e-Factura invoices locally, on a schedule.",
|
|
33
|
+
no_args_is_help=True,
|
|
34
|
+
)
|
|
35
|
+
schedule_app = typer.Typer(help="Manage the OS-level schedule for `anaf-sync sync`.")
|
|
36
|
+
app.add_typer(schedule_app, name="schedule")
|
|
37
|
+
|
|
38
|
+
_CONFIG_ENV = "ANAF_SYNC_CONFIG"
|
|
39
|
+
|
|
40
|
+
ConfigOption = Annotated[
|
|
41
|
+
Path | None,
|
|
42
|
+
typer.Option(
|
|
43
|
+
"--config",
|
|
44
|
+
"-c",
|
|
45
|
+
help=f"Config file (default: ${_CONFIG_ENV} or the platform config dir).",
|
|
46
|
+
),
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _configure_logging(verbose: bool) -> None:
|
|
51
|
+
level = logging.DEBUG if verbose else logging.INFO
|
|
52
|
+
structlog.configure(
|
|
53
|
+
wrapper_class=structlog.make_filtering_bound_logger(level),
|
|
54
|
+
processors=[
|
|
55
|
+
structlog.processors.add_log_level,
|
|
56
|
+
structlog.processors.TimeStamper(fmt="%H:%M:%S"),
|
|
57
|
+
structlog.dev.ConsoleRenderer(),
|
|
58
|
+
],
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _resolve_config_path(option: Path | None) -> Path:
|
|
63
|
+
if option is not None:
|
|
64
|
+
return option.expanduser()
|
|
65
|
+
if env := os.environ.get(_CONFIG_ENV):
|
|
66
|
+
return Path(env).expanduser()
|
|
67
|
+
return default_config_path()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@app.callback()
|
|
71
|
+
def _main(
|
|
72
|
+
verbose: Annotated[
|
|
73
|
+
bool, typer.Option("--verbose", "-v", help="Debug logging.")
|
|
74
|
+
] = False,
|
|
75
|
+
) -> None:
|
|
76
|
+
_configure_logging(verbose)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@app.command()
|
|
80
|
+
def version() -> None:
|
|
81
|
+
"""Print the anaf-sync version."""
|
|
82
|
+
typer.echo(f"anaf-sync {__version__}")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@app.command()
|
|
86
|
+
def init(
|
|
87
|
+
config: ConfigOption = None,
|
|
88
|
+
force: Annotated[
|
|
89
|
+
bool, typer.Option("--force", help="Overwrite an existing config.")
|
|
90
|
+
] = False,
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Write a commented default configuration file."""
|
|
93
|
+
path = _resolve_config_path(config)
|
|
94
|
+
try:
|
|
95
|
+
write_default_config(path, force=force)
|
|
96
|
+
except FileExistsError as exc:
|
|
97
|
+
typer.echo(f"error: {exc}", err=True)
|
|
98
|
+
raise typer.Exit(code=1) from exc
|
|
99
|
+
typer.echo(f"wrote {path}")
|
|
100
|
+
typer.echo("Edit it (CIF, output folder, template), then run: anaf-sync sync")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@app.command()
|
|
104
|
+
def sync(
|
|
105
|
+
config: ConfigOption = None,
|
|
106
|
+
days: Annotated[
|
|
107
|
+
int | None,
|
|
108
|
+
typer.Option(min=1, max=60, help="Override the lookback window."),
|
|
109
|
+
] = None,
|
|
110
|
+
dry_run: Annotated[
|
|
111
|
+
bool,
|
|
112
|
+
typer.Option("--dry-run", help="List what would be downloaded; write nothing."),
|
|
113
|
+
] = False,
|
|
114
|
+
redownload: Annotated[
|
|
115
|
+
bool,
|
|
116
|
+
typer.Option(
|
|
117
|
+
"--redownload", help="Ignore the state file and fetch everything."
|
|
118
|
+
),
|
|
119
|
+
] = False,
|
|
120
|
+
) -> None:
|
|
121
|
+
"""Download all new e-Factura invoices into the archive."""
|
|
122
|
+
config_path = _resolve_config_path(config)
|
|
123
|
+
try:
|
|
124
|
+
cfg = load_config(config_path)
|
|
125
|
+
provider = AuthSettings.from_env().build_provider()
|
|
126
|
+
state = SyncState.load(default_state_path())
|
|
127
|
+
report = asyncio.run(
|
|
128
|
+
run_sync(
|
|
129
|
+
cfg,
|
|
130
|
+
provider,
|
|
131
|
+
state,
|
|
132
|
+
days=days,
|
|
133
|
+
dry_run=dry_run,
|
|
134
|
+
redownload=redownload,
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
except (AnafError, FileNotFoundError, ValueError) as exc:
|
|
138
|
+
typer.echo(f"error: {exc}", err=True)
|
|
139
|
+
raise typer.Exit(code=1) from exc
|
|
140
|
+
|
|
141
|
+
typer.echo(
|
|
142
|
+
f"listed {report.listed} | new {report.downloaded} | "
|
|
143
|
+
f"already archived {report.already_archived} | "
|
|
144
|
+
f"non-invoice {report.skipped_non_invoice}"
|
|
145
|
+
+ (f" | would download {report.would_download}" if dry_run else "")
|
|
146
|
+
)
|
|
147
|
+
if report.failures:
|
|
148
|
+
for message_id, error in report.failures:
|
|
149
|
+
typer.echo(f"failed {message_id}: {error}", err=True)
|
|
150
|
+
raise typer.Exit(code=1)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@app.command()
|
|
154
|
+
def status(config: ConfigOption = None) -> None:
|
|
155
|
+
"""Show configuration, archive state, and schedule status."""
|
|
156
|
+
config_path = _resolve_config_path(config)
|
|
157
|
+
typer.echo(
|
|
158
|
+
f"config: {config_path} ({'ok' if config_path.exists() else 'MISSING'})"
|
|
159
|
+
)
|
|
160
|
+
state = SyncState.load(default_state_path())
|
|
161
|
+
typer.echo(f"state: {state.path} ({state.count} messages archived)")
|
|
162
|
+
for message_id, failure in state.failures.items():
|
|
163
|
+
typer.echo(
|
|
164
|
+
f"failing: {message_id} — {failure.attempts} run(s) since "
|
|
165
|
+
f"{failure.first_failed_at:%Y-%m-%d}: {failure.error}"
|
|
166
|
+
)
|
|
167
|
+
if config_path.exists():
|
|
168
|
+
cfg = load_config(config_path)
|
|
169
|
+
typer.echo(f"cifs: {', '.join(cfg.cifs)} [{cfg.direction.value}]")
|
|
170
|
+
typer.echo(f"output: {cfg.output.resolved_directory}")
|
|
171
|
+
typer.echo(f"template: {cfg.output.template}")
|
|
172
|
+
typer.echo(f"schedule: {schedule_status()}")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@schedule_app.command("install")
|
|
176
|
+
def schedule_install_cmd(
|
|
177
|
+
every: Annotated[
|
|
178
|
+
str | None,
|
|
179
|
+
typer.Option("--every", help="Run interval, e.g. 30m, 6h, 1d."),
|
|
180
|
+
] = None,
|
|
181
|
+
daily_at: Annotated[
|
|
182
|
+
str | None,
|
|
183
|
+
typer.Option("--daily-at", help="Run once a day at HH:MM (24h)."),
|
|
184
|
+
] = None,
|
|
185
|
+
) -> None:
|
|
186
|
+
"""Register `anaf-sync sync` with the OS scheduler.
|
|
187
|
+
|
|
188
|
+
Uses Task Scheduler on Windows, a systemd user timer on Linux, and
|
|
189
|
+
launchd on macOS.
|
|
190
|
+
"""
|
|
191
|
+
try:
|
|
192
|
+
typer.echo(schedule_install(every=every, daily_at=daily_at))
|
|
193
|
+
except ScheduleError as exc:
|
|
194
|
+
typer.echo(f"error: {exc}", err=True)
|
|
195
|
+
raise typer.Exit(code=1) from exc
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@schedule_app.command("remove")
|
|
199
|
+
def schedule_remove_cmd() -> None:
|
|
200
|
+
"""Remove the scheduled job."""
|
|
201
|
+
try:
|
|
202
|
+
typer.echo(schedule_uninstall())
|
|
203
|
+
except ScheduleError as exc:
|
|
204
|
+
typer.echo(f"error: {exc}", err=True)
|
|
205
|
+
raise typer.Exit(code=1) from exc
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@schedule_app.command("status")
|
|
209
|
+
def schedule_status_cmd() -> None:
|
|
210
|
+
"""Show whether the scheduled job is installed."""
|
|
211
|
+
typer.echo(schedule_status())
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
if __name__ == "__main__":
|
|
215
|
+
app()
|
anaf_sync/config.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Configuration: a TOML file for sync behaviour, environment variables for auth.
|
|
2
|
+
|
|
3
|
+
Credentials deliberately reuse anafpy's own conventions (``ANAFPY_CLIENT_ID``,
|
|
4
|
+
``ANAFPY_CLIENT_SECRET``, ``ANAFPY_TOKEN_STORE``, ``ANAFPY_TOKEN_STORE_BACKEND``)
|
|
5
|
+
so the login performed with ``anafpy auth login`` — CLI or MCP server — is shared
|
|
6
|
+
as-is. Everything behavioural (CIFs, window, output template, artifacts) lives in
|
|
7
|
+
a TOML file the user owns.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import tomllib
|
|
13
|
+
from enum import StrEnum
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Literal, Self
|
|
16
|
+
|
|
17
|
+
import platformdirs
|
|
18
|
+
from anafpy.auth import FileTokenStore, KeyringTokenStore, TokenProvider, TokenStore
|
|
19
|
+
from anafpy.exceptions import AnafConfigError
|
|
20
|
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
21
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"APP_NAME",
|
|
25
|
+
"Artifact",
|
|
26
|
+
"AuthSettings",
|
|
27
|
+
"Direction",
|
|
28
|
+
"OutputConfig",
|
|
29
|
+
"SyncConfig",
|
|
30
|
+
"default_config_path",
|
|
31
|
+
"default_state_path",
|
|
32
|
+
"load_config",
|
|
33
|
+
"write_default_config",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
APP_NAME = "anaf-sync"
|
|
37
|
+
|
|
38
|
+
_DEFAULT_TEMPLATE = (
|
|
39
|
+
"{cif}/{direction}/{issue_date:%Y}/{issue_date:%m}/"
|
|
40
|
+
"{issue_date:%Y-%m-%d}_{number}_{partner_name}"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Direction(StrEnum):
|
|
45
|
+
"""Which side of the exchange to archive."""
|
|
46
|
+
|
|
47
|
+
RECEIVED = "received"
|
|
48
|
+
SENT = "sent"
|
|
49
|
+
BOTH = "both"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Artifact(StrEnum):
|
|
53
|
+
"""What to write to disk for each downloaded message."""
|
|
54
|
+
|
|
55
|
+
ZIP = "zip" # the raw descarcare ZIP — the legally meaningful archive
|
|
56
|
+
XML = "xml" # the invoice UBL XML extracted from the ZIP
|
|
57
|
+
SIGNATURE = "signature" # the detached MF signature XML
|
|
58
|
+
PDF = "pdf" # ANAF's own PDF rendering (public transformare service)
|
|
59
|
+
METADATA = "metadata" # a small JSON sidecar with the message + summary
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class OutputConfig(BaseModel):
|
|
63
|
+
"""Where and how downloaded invoices are written."""
|
|
64
|
+
|
|
65
|
+
directory: Path = Path("~/Facturi")
|
|
66
|
+
template: str = _DEFAULT_TEMPLATE
|
|
67
|
+
artifacts: list[Artifact] = [Artifact.ZIP, Artifact.PDF]
|
|
68
|
+
|
|
69
|
+
@field_validator("artifacts")
|
|
70
|
+
@classmethod
|
|
71
|
+
def _non_empty(cls, value: list[Artifact]) -> list[Artifact]:
|
|
72
|
+
if not value:
|
|
73
|
+
raise ValueError("output.artifacts cannot be empty")
|
|
74
|
+
return value
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def resolved_directory(self) -> Path:
|
|
78
|
+
return self.directory.expanduser()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class SyncConfig(BaseModel):
|
|
82
|
+
"""The TOML configuration file, validated."""
|
|
83
|
+
|
|
84
|
+
cifs: list[str] = Field(min_length=1)
|
|
85
|
+
direction: Direction = Direction.RECEIVED
|
|
86
|
+
lookback_days: int = Field(default=60, ge=1, le=60)
|
|
87
|
+
# Floor of 60: pruning records ANAF can still list would re-download them.
|
|
88
|
+
state_retention_days: int = Field(default=90, ge=60)
|
|
89
|
+
output: OutputConfig = OutputConfig()
|
|
90
|
+
|
|
91
|
+
@model_validator(mode="before")
|
|
92
|
+
@classmethod
|
|
93
|
+
def _accept_single_cif(cls, data: object) -> object:
|
|
94
|
+
# `cif = "123"` is friendlier than a one-element list; accept both.
|
|
95
|
+
if isinstance(data, dict) and "cif" in data and "cifs" not in data:
|
|
96
|
+
data = dict(data)
|
|
97
|
+
data["cifs"] = [data.pop("cif")]
|
|
98
|
+
return data
|
|
99
|
+
|
|
100
|
+
@field_validator("cifs")
|
|
101
|
+
@classmethod
|
|
102
|
+
def _digits_only(cls, value: list[str]) -> list[str]:
|
|
103
|
+
cleaned = [str(cif).strip().upper().removeprefix("RO") for cif in value]
|
|
104
|
+
for cif in cleaned:
|
|
105
|
+
if not cif.isdigit():
|
|
106
|
+
raise ValueError(f"CIF {cif!r} is not numeric (drop any RO prefix)")
|
|
107
|
+
return cleaned
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class AuthSettings(BaseSettings):
|
|
111
|
+
"""ANAF OAuth credentials and token-store location, from ``ANAFPY_*`` env vars.
|
|
112
|
+
|
|
113
|
+
Mirrors anafpy's own MCP-server config so one login serves every consumer.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
|
117
|
+
|
|
118
|
+
client_id: str | None = Field(default=None, validation_alias="ANAFPY_CLIENT_ID")
|
|
119
|
+
client_secret: str | None = Field(
|
|
120
|
+
default=None, validation_alias="ANAFPY_CLIENT_SECRET"
|
|
121
|
+
)
|
|
122
|
+
token_store_path: Path = Field(
|
|
123
|
+
default=Path("~/.anafpy/tokens.json"), validation_alias="ANAFPY_TOKEN_STORE"
|
|
124
|
+
)
|
|
125
|
+
token_store_backend: Literal["keyring", "file"] = Field(
|
|
126
|
+
default="keyring", validation_alias="ANAFPY_TOKEN_STORE_BACKEND"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def build_store(self) -> TokenStore:
|
|
130
|
+
if self.token_store_backend == "file":
|
|
131
|
+
return FileTokenStore(self.token_store_path)
|
|
132
|
+
return KeyringTokenStore()
|
|
133
|
+
|
|
134
|
+
def build_provider(self) -> TokenProvider:
|
|
135
|
+
"""A refreshing token provider over the shared store.
|
|
136
|
+
|
|
137
|
+
Raises:
|
|
138
|
+
AnafConfigError: the OAuth client credentials are missing — without
|
|
139
|
+
them expired access tokens cannot be refreshed, which a scheduled
|
|
140
|
+
job depends on.
|
|
141
|
+
"""
|
|
142
|
+
if not self.client_id or not self.client_secret:
|
|
143
|
+
raise AnafConfigError(
|
|
144
|
+
"ANAFPY_CLIENT_ID / ANAFPY_CLIENT_SECRET are not set — anaf-sync "
|
|
145
|
+
"needs them to refresh tokens between scheduled runs"
|
|
146
|
+
)
|
|
147
|
+
return TokenProvider(
|
|
148
|
+
client_id=self.client_id,
|
|
149
|
+
client_secret=self.client_secret,
|
|
150
|
+
store=self.build_store(),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
@classmethod
|
|
154
|
+
def from_env(cls) -> Self:
|
|
155
|
+
return cls()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def default_config_path() -> Path:
|
|
159
|
+
"""``config.toml`` in the platform config dir (roaming on Windows)."""
|
|
160
|
+
return platformdirs.user_config_path(APP_NAME, appauthor=False) / "config.toml"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def default_state_path() -> Path:
|
|
164
|
+
"""``state.json`` in the platform state dir (survives config wipes)."""
|
|
165
|
+
return platformdirs.user_state_path(APP_NAME, appauthor=False) / "state.json"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def load_config(path: Path) -> SyncConfig:
|
|
169
|
+
"""Load and validate the TOML configuration.
|
|
170
|
+
|
|
171
|
+
Raises:
|
|
172
|
+
FileNotFoundError: no config file at ``path`` — run ``anaf-sync init``.
|
|
173
|
+
"""
|
|
174
|
+
if not path.exists():
|
|
175
|
+
raise FileNotFoundError(
|
|
176
|
+
f"no configuration at {path} — run `anaf-sync init` to create one"
|
|
177
|
+
)
|
|
178
|
+
with path.open("rb") as fh:
|
|
179
|
+
data = tomllib.load(fh)
|
|
180
|
+
return SyncConfig.model_validate(data)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
_DEFAULT_CONFIG_TOML = f"""\
|
|
184
|
+
# anaf-sync configuration.
|
|
185
|
+
#
|
|
186
|
+
# Credentials are NOT stored here: anaf-sync reuses the anafpy login
|
|
187
|
+
# (`anafpy auth login`) plus the ANAFPY_CLIENT_ID / ANAFPY_CLIENT_SECRET
|
|
188
|
+
# environment variables, exactly like the anafpy MCP server.
|
|
189
|
+
|
|
190
|
+
# The company CIF(s) to archive invoices for (numeric, no RO prefix).
|
|
191
|
+
cif = "12345678"
|
|
192
|
+
# ...or several: cifs = ["12345678", "87654321"]
|
|
193
|
+
|
|
194
|
+
# Which invoices to download: "received", "sent", or "both".
|
|
195
|
+
direction = "received"
|
|
196
|
+
|
|
197
|
+
# How far back each run looks (1-60; ANAF purges messages after 60 days).
|
|
198
|
+
lookback_days = 60
|
|
199
|
+
|
|
200
|
+
# How long to keep state-file records of already-archived messages, in days
|
|
201
|
+
# (minimum 60). Past ANAF's 60-day window a message can never be listed again,
|
|
202
|
+
# so older records are pruned at the start of each run to keep state.json small.
|
|
203
|
+
state_retention_days = 90
|
|
204
|
+
|
|
205
|
+
[output]
|
|
206
|
+
# Root folder for the archive (created if missing; ~ is expanded).
|
|
207
|
+
directory = "~/Facturi"
|
|
208
|
+
|
|
209
|
+
# Where each invoice lands, relative to `directory`, as a template over the
|
|
210
|
+
# invoice's context variables. Python format-spec syntax applies, so dates
|
|
211
|
+
# support strftime specs like {{issue_date:%Y}}. Available variables:
|
|
212
|
+
#
|
|
213
|
+
# number invoice number (BT-1)
|
|
214
|
+
# issue_date issue date (a real date - format with strftime specs)
|
|
215
|
+
# due_date due date, when present
|
|
216
|
+
# currency invoice currency code
|
|
217
|
+
# total payable amount
|
|
218
|
+
# kind "invoice" or "credit_note"
|
|
219
|
+
# direction "received" or "sent"
|
|
220
|
+
# cif the CIF this sync run queried
|
|
221
|
+
# seller_name seller legal name seller_cif seller CIF (digits)
|
|
222
|
+
# buyer_name buyer legal name buyer_cif buyer CIF (digits)
|
|
223
|
+
# partner_name the other party's name partner_cif the other party's CIF
|
|
224
|
+
# message_id ANAF download id request_id ANAF upload id
|
|
225
|
+
# message_type ANAF message type created message creation time
|
|
226
|
+
#
|
|
227
|
+
# Values are sanitised for the filesystem; "/" in the template creates folders.
|
|
228
|
+
# Do not add an extension - each artifact appends its own (.zip, .xml, ...).
|
|
229
|
+
template = "{_DEFAULT_TEMPLATE}"
|
|
230
|
+
|
|
231
|
+
# What to save per invoice: "zip" (raw signed archive), "xml" (invoice UBL),
|
|
232
|
+
# "signature" (detached MF signature), "pdf" (ANAF's rendering), "metadata"
|
|
233
|
+
# (JSON sidecar with the message details).
|
|
234
|
+
artifacts = ["zip", "pdf"]
|
|
235
|
+
"""
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def write_default_config(path: Path, *, force: bool = False) -> Path:
|
|
239
|
+
"""Write the commented default config to ``path``.
|
|
240
|
+
|
|
241
|
+
Raises:
|
|
242
|
+
FileExistsError: the file exists and ``force`` is not set.
|
|
243
|
+
"""
|
|
244
|
+
if path.exists() and not force:
|
|
245
|
+
raise FileExistsError(f"{path} already exists (use --force to overwrite)")
|
|
246
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
247
|
+
path.write_text(_DEFAULT_CONFIG_TOML, encoding="utf-8")
|
|
248
|
+
return path
|
anaf_sync/context.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Build the template context for one downloaded e-Factura message.
|
|
2
|
+
|
|
3
|
+
Every variable the path template may reference is assembled here, from two
|
|
4
|
+
sources: the message-list entry (always present) and the parsed invoice view
|
|
5
|
+
(present when the downloaded content is a readable UBL invoice/credit note).
|
|
6
|
+
Missing values stay ``None`` — the template renders them as ``unknown``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import datetime as dt
|
|
12
|
+
from decimal import Decimal
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from anafpy.efactura import MessageListItem
|
|
16
|
+
from anafpy.efactura.authoring import InvoiceDocument
|
|
17
|
+
|
|
18
|
+
__all__ = ["Direction", "build_context", "direction_of"]
|
|
19
|
+
|
|
20
|
+
Direction = str # "received" | "sent" | None — narrow alias for readability
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def direction_of(item: MessageListItem) -> Direction | None:
|
|
24
|
+
"""Classify a message as received/sent from ANAF's ``tip`` field."""
|
|
25
|
+
kind = (item.message_type or "").casefold()
|
|
26
|
+
if "primita" in kind:
|
|
27
|
+
return "received"
|
|
28
|
+
if "trimisa" in kind:
|
|
29
|
+
return "sent"
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _parse_created(raw: str | None) -> dt.datetime | None:
|
|
34
|
+
"""ANAF's ``data_creare`` is ``yyyymmddhhmm``; be lenient about it."""
|
|
35
|
+
if not raw:
|
|
36
|
+
return None
|
|
37
|
+
try:
|
|
38
|
+
return dt.datetime.strptime(raw.strip(), "%Y%m%d%H%M")
|
|
39
|
+
except ValueError:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _digits(value: str | None) -> str | None:
|
|
44
|
+
"""A CIF as plain digits, from either ``123`` or ``RO123`` shapes."""
|
|
45
|
+
if not value:
|
|
46
|
+
return None
|
|
47
|
+
cleaned = value.strip().upper().removeprefix("RO")
|
|
48
|
+
return cleaned if cleaned.isdigit() else None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _party_cif(vat_id: str | None, *fallbacks: str | None) -> str | None:
|
|
52
|
+
for candidate in (vat_id, *fallbacks):
|
|
53
|
+
if digits := _digits(candidate):
|
|
54
|
+
return digits
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def build_context(
|
|
59
|
+
item: MessageListItem,
|
|
60
|
+
view: InvoiceDocument | None,
|
|
61
|
+
*,
|
|
62
|
+
cif: str,
|
|
63
|
+
) -> dict[str, Any]:
|
|
64
|
+
"""The full variable set available to the path template.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
item: the message-list entry the download originated from.
|
|
68
|
+
view: the parsed flat invoice, when the content was readable UBL.
|
|
69
|
+
cif: the CIF this sync run queried (the "own" company).
|
|
70
|
+
"""
|
|
71
|
+
direction = direction_of(item)
|
|
72
|
+
|
|
73
|
+
number: str | None = None
|
|
74
|
+
issue_date: dt.date | None = None
|
|
75
|
+
due_date: dt.date | None = None
|
|
76
|
+
currency: str | None = None
|
|
77
|
+
total: Decimal | None = None
|
|
78
|
+
kind: str | None = None
|
|
79
|
+
seller_name: str | None = None
|
|
80
|
+
seller_cif: str | None = None
|
|
81
|
+
buyer_name: str | None = None
|
|
82
|
+
buyer_cif: str | None = None
|
|
83
|
+
|
|
84
|
+
if view is not None:
|
|
85
|
+
number = view.number
|
|
86
|
+
issue_date = view.issue_date
|
|
87
|
+
due_date = view.due_date
|
|
88
|
+
currency = str(view.currency)
|
|
89
|
+
kind = view.kind.value
|
|
90
|
+
seller_name = view.seller.name
|
|
91
|
+
seller_cif = _party_cif(
|
|
92
|
+
view.seller.vat_id, view.seller.tax_registration_id, item.sender_cif
|
|
93
|
+
)
|
|
94
|
+
buyer_name = view.buyer.name
|
|
95
|
+
buyer_cif = _party_cif(view.buyer.vat_id, item.receiver_cif)
|
|
96
|
+
try:
|
|
97
|
+
total = view.effective_totals().payable
|
|
98
|
+
except Exception: # totals are auxiliary context — never fail the archive
|
|
99
|
+
total = None
|
|
100
|
+
else:
|
|
101
|
+
seller_cif = _digits(item.sender_cif)
|
|
102
|
+
buyer_cif = _digits(item.receiver_cif)
|
|
103
|
+
|
|
104
|
+
if direction == "sent":
|
|
105
|
+
partner_name, partner_cif = buyer_name, buyer_cif
|
|
106
|
+
else:
|
|
107
|
+
# Received — and the safe default when the type is unrecognised.
|
|
108
|
+
partner_name, partner_cif = seller_name, seller_cif
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
"message_id": item.id,
|
|
112
|
+
"request_id": item.request_id,
|
|
113
|
+
"message_type": item.message_type,
|
|
114
|
+
"created": _parse_created(item.created_at),
|
|
115
|
+
"cif": _digits(cif) or cif,
|
|
116
|
+
"direction": direction,
|
|
117
|
+
"number": number,
|
|
118
|
+
"issue_date": issue_date,
|
|
119
|
+
"due_date": due_date,
|
|
120
|
+
"currency": currency,
|
|
121
|
+
"total": total,
|
|
122
|
+
"kind": kind,
|
|
123
|
+
"seller_name": seller_name,
|
|
124
|
+
"seller_cif": seller_cif,
|
|
125
|
+
"buyer_name": buyer_name,
|
|
126
|
+
"buyer_cif": buyer_cif,
|
|
127
|
+
"partner_name": partner_name,
|
|
128
|
+
"partner_cif": partner_cif,
|
|
129
|
+
}
|