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 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
+ }