anaf-sync 0.1.0__tar.gz
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-0.1.0/.github/workflows/ci.yml +53 -0
- anaf_sync-0.1.0/.github/workflows/release.yml +74 -0
- anaf_sync-0.1.0/.gitignore +14 -0
- anaf_sync-0.1.0/.python-version +1 -0
- anaf_sync-0.1.0/CLAUDE.md +82 -0
- anaf_sync-0.1.0/DESIGN.md +219 -0
- anaf_sync-0.1.0/PKG-INFO +111 -0
- anaf_sync-0.1.0/README.md +97 -0
- anaf_sync-0.1.0/pyproject.toml +70 -0
- anaf_sync-0.1.0/src/anaf_sync/__init__.py +13 -0
- anaf_sync-0.1.0/src/anaf_sync/cli.py +215 -0
- anaf_sync-0.1.0/src/anaf_sync/config.py +248 -0
- anaf_sync-0.1.0/src/anaf_sync/context.py +129 -0
- anaf_sync-0.1.0/src/anaf_sync/engine.py +282 -0
- anaf_sync-0.1.0/src/anaf_sync/scheduling.py +246 -0
- anaf_sync-0.1.0/src/anaf_sync/state.py +139 -0
- anaf_sync-0.1.0/src/anaf_sync/template.py +99 -0
- anaf_sync-0.1.0/tests/test_config.py +51 -0
- anaf_sync-0.1.0/tests/test_context.py +73 -0
- anaf_sync-0.1.0/tests/test_engine.py +184 -0
- anaf_sync-0.1.0/tests/test_scheduling.py +33 -0
- anaf_sync-0.1.0/tests/test_state.py +90 -0
- anaf_sync-0.1.0/tests/test_template.py +52 -0
- anaf_sync-0.1.0/uv.lock +1200 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Credential-free gates on every push/PR: the fake-client test suite, ruff,
|
|
2
|
+
# black, and mypy --strict, across the supported Python versions. The tests
|
|
3
|
+
# additionally run on macOS and Windows — path handling and the scheduler
|
|
4
|
+
# layer (schtasks / systemd user / launchd) are platform-dispatched and their
|
|
5
|
+
# shared code must stay green everywhere.
|
|
6
|
+
name: CI
|
|
7
|
+
|
|
8
|
+
on:
|
|
9
|
+
push:
|
|
10
|
+
branches: [main]
|
|
11
|
+
pull_request:
|
|
12
|
+
|
|
13
|
+
# Nothing here writes back to the repo — checkout is the only token consumer.
|
|
14
|
+
permissions:
|
|
15
|
+
contents: read
|
|
16
|
+
|
|
17
|
+
jobs:
|
|
18
|
+
test:
|
|
19
|
+
runs-on: ${{ matrix.os }}
|
|
20
|
+
strategy:
|
|
21
|
+
fail-fast: false
|
|
22
|
+
matrix:
|
|
23
|
+
os: [ubuntu-latest, macos-latest, windows-latest]
|
|
24
|
+
python-version: ["3.12", "3.13"]
|
|
25
|
+
steps:
|
|
26
|
+
- uses: actions/checkout@v7
|
|
27
|
+
- uses: astral-sh/setup-uv@v8.3.0
|
|
28
|
+
with:
|
|
29
|
+
python-version: ${{ matrix.python-version }}
|
|
30
|
+
enable-cache: true
|
|
31
|
+
- name: Sync dependencies
|
|
32
|
+
run: uv sync
|
|
33
|
+
- name: Tests
|
|
34
|
+
run: uv run pytest -q --cov --cov-report=xml
|
|
35
|
+
# Every leg uploads; Codecov merges them per commit — the macOS/Windows
|
|
36
|
+
# legs execute scheduler branches the ubuntu leg never does. A Codecov
|
|
37
|
+
# outage must not break the gates, hence fail_ci_if_error stays off.
|
|
38
|
+
- name: Upload coverage to Codecov
|
|
39
|
+
uses: codecov/codecov-action@v7
|
|
40
|
+
with:
|
|
41
|
+
token: ${{ secrets.CODECOV_TOKEN }}
|
|
42
|
+
flags: ${{ matrix.os }}-py${{ matrix.python-version }}
|
|
43
|
+
fail_ci_if_error: false
|
|
44
|
+
# The static gates are platform-independent; run them once per Python.
|
|
45
|
+
- name: Ruff lint
|
|
46
|
+
if: runner.os == 'Linux'
|
|
47
|
+
run: uv run ruff check src tests
|
|
48
|
+
- name: Black format
|
|
49
|
+
if: runner.os == 'Linux'
|
|
50
|
+
run: uv run black --check src tests
|
|
51
|
+
- name: Mypy (strict)
|
|
52
|
+
if: runner.os == 'Linux'
|
|
53
|
+
run: uv run mypy src
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Publish to PyPI on a version tag (v*) via trusted publishing (OIDC) — no
|
|
2
|
+
# API token is stored in the repo. PyPI side: the project's trusted publisher
|
|
3
|
+
# must name this repo, this filename (release.yml), and the `pypi` environment.
|
|
4
|
+
# The full CI gates re-run here so a tag can never ship an artifact that a
|
|
5
|
+
# broken branch produced.
|
|
6
|
+
name: Release
|
|
7
|
+
|
|
8
|
+
on:
|
|
9
|
+
push:
|
|
10
|
+
tags: ["v*"]
|
|
11
|
+
|
|
12
|
+
# Least privilege by default; the publish job re-declares what it actually needs
|
|
13
|
+
# (a job-level block replaces this one rather than adding to it).
|
|
14
|
+
permissions:
|
|
15
|
+
contents: read
|
|
16
|
+
|
|
17
|
+
jobs:
|
|
18
|
+
gates:
|
|
19
|
+
runs-on: ubuntu-latest
|
|
20
|
+
strategy:
|
|
21
|
+
fail-fast: false
|
|
22
|
+
matrix:
|
|
23
|
+
python-version: ["3.12", "3.13"]
|
|
24
|
+
steps:
|
|
25
|
+
- uses: actions/checkout@v7
|
|
26
|
+
- uses: astral-sh/setup-uv@v8.3.0
|
|
27
|
+
with:
|
|
28
|
+
python-version: ${{ matrix.python-version }}
|
|
29
|
+
enable-cache: true
|
|
30
|
+
- name: Sync dependencies
|
|
31
|
+
run: uv sync
|
|
32
|
+
- name: Tests
|
|
33
|
+
run: uv run pytest -q
|
|
34
|
+
- name: Ruff lint
|
|
35
|
+
run: uv run ruff check src tests
|
|
36
|
+
- name: Black format
|
|
37
|
+
run: uv run black --check src tests
|
|
38
|
+
- name: Mypy (strict)
|
|
39
|
+
run: uv run mypy src
|
|
40
|
+
|
|
41
|
+
build:
|
|
42
|
+
needs: gates
|
|
43
|
+
runs-on: ubuntu-latest
|
|
44
|
+
steps:
|
|
45
|
+
- uses: actions/checkout@v7
|
|
46
|
+
- uses: astral-sh/setup-uv@v8.3.0
|
|
47
|
+
- name: Tag matches the package version
|
|
48
|
+
run: |
|
|
49
|
+
version="$(python3 -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')"
|
|
50
|
+
if [ "v${version}" != "${GITHUB_REF_NAME}" ]; then
|
|
51
|
+
echo "pyproject version ${version} does not match tag ${GITHUB_REF_NAME}" >&2
|
|
52
|
+
exit 1
|
|
53
|
+
fi
|
|
54
|
+
- name: Build sdist + wheel
|
|
55
|
+
run: uv build
|
|
56
|
+
- uses: actions/upload-artifact@v7
|
|
57
|
+
with:
|
|
58
|
+
name: dist
|
|
59
|
+
path: dist/
|
|
60
|
+
|
|
61
|
+
publish:
|
|
62
|
+
needs: build
|
|
63
|
+
runs-on: ubuntu-latest
|
|
64
|
+
environment:
|
|
65
|
+
name: pypi
|
|
66
|
+
url: https://pypi.org/p/anaf-sync
|
|
67
|
+
permissions:
|
|
68
|
+
id-token: write # OIDC for PyPI trusted publishing
|
|
69
|
+
steps:
|
|
70
|
+
- uses: actions/download-artifact@v8
|
|
71
|
+
with:
|
|
72
|
+
name: dist
|
|
73
|
+
path: dist/
|
|
74
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.12
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
Guidance for Claude Code when working in this repository.
|
|
4
|
+
|
|
5
|
+
## What this is
|
|
6
|
+
|
|
7
|
+
`anaf-sync` is a cross-platform (Windows/Linux/macOS) CLI that archives RO
|
|
8
|
+
e-Factura invoices locally on a schedule. It is a thin, deliberate layer over
|
|
9
|
+
[anafpy](https://github.com/robert-malai/anafpy) — Robert's own package, which
|
|
10
|
+
also powers his local anafpy MCP server. Design rationale lives in
|
|
11
|
+
[DESIGN.md](DESIGN.md); read it before changing architecture-level behaviour.
|
|
12
|
+
|
|
13
|
+
## Commands
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
uv sync # install deps (incl. dev group)
|
|
17
|
+
uv run pytest -q # tests
|
|
18
|
+
uv run ruff check src tests # lint
|
|
19
|
+
uv run black --check src tests # format check (black writes; ruff checks)
|
|
20
|
+
uv run mypy src # strict typing — must stay clean
|
|
21
|
+
uv run anaf-sync --help # run the CLI from the venv
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
All four gates (pytest, ruff, black, mypy --strict) must pass before a change
|
|
25
|
+
is considered done.
|
|
26
|
+
|
|
27
|
+
## Architecture map
|
|
28
|
+
|
|
29
|
+
| Module | Responsibility |
|
|
30
|
+
|---|---|
|
|
31
|
+
| `cli.py` | typer commands; the only place exceptions are caught for the user |
|
|
32
|
+
| `config.py` | TOML sync config + `ANAFPY_*` env auth settings; `init` template |
|
|
33
|
+
| `engine.py` | one sync pass: list → dedupe → download (retry) → write artifacts |
|
|
34
|
+
| `context.py` | assembles the template variable dict for one message |
|
|
35
|
+
| `template.py` | `str.format`-based path template, sanitised per substitution |
|
|
36
|
+
| `state.py` | JSON record of archived message ids (idempotence) + failure traces (visibility only, never a retry gate) |
|
|
37
|
+
| `scheduling.py` | registers `anaf-sync sync` with schtasks / systemd user / launchd |
|
|
38
|
+
|
|
39
|
+
## Invariants — do not break
|
|
40
|
+
|
|
41
|
+
- **Auth is anafpy's, not ours.** Credentials come from `ANAFPY_CLIENT_ID` /
|
|
42
|
+
`ANAFPY_CLIENT_SECRET` and the token store written by `anafpy auth login`
|
|
43
|
+
(`ANAFPY_TOKEN_STORE`, `ANAFPY_TOKEN_STORE_BACKEND`). Never introduce
|
|
44
|
+
anaf-sync-specific credential storage or config keys.
|
|
45
|
+
- **Idempotence.** `state.json` is saved atomically after *every* archived
|
|
46
|
+
message. A crash mid-run must never lose or duplicate work.
|
|
47
|
+
- **Path safety.** Every substituted template value is sanitised
|
|
48
|
+
(`template.py`); rendered paths must stay relative and inside the output
|
|
49
|
+
root. Windows-illegal characters and trailing dots/spaces are handled there
|
|
50
|
+
— keep any new path logic behind that choke point.
|
|
51
|
+
- **Error philosophy** (mirrors anafpy): business outcomes are values,
|
|
52
|
+
exceptions propagate; catch only at boundaries. In the engine, a per-message
|
|
53
|
+
`AnafError` is recorded in the report and the run continues; everything else
|
|
54
|
+
crashes the run on purpose. The CLI is the only layer that formats errors
|
|
55
|
+
for humans and sets exit codes.
|
|
56
|
+
- **Cross-platform.** Anything touching paths, schedulers, or consoles must
|
|
57
|
+
work on Windows, Linux, and macOS. No POSIX-only assumptions outside the
|
|
58
|
+
platform-dispatched branches of `scheduling.py`.
|
|
59
|
+
|
|
60
|
+
## Sharp edges
|
|
61
|
+
|
|
62
|
+
- ANAF retains messages for **60 days** and rejects older windows; the
|
|
63
|
+
1–60 bound on `lookback_days`/`--days` is ANAF's rule, not ours.
|
|
64
|
+
- The message listing never carries party CIFs as JSON fields; anafpy extracts
|
|
65
|
+
them from the `detalii` prose. `context.py` treats them as best-effort.
|
|
66
|
+
- `DownloadedMessage.view` is `None` for non-UBL content (nok error files,
|
|
67
|
+
buyer messages) *and* for rule-drift — the template must render regardless
|
|
68
|
+
(missing values become `unknown`).
|
|
69
|
+
- anafpy's API is best learned from the installed source under
|
|
70
|
+
`.venv/lib/python3.12/site-packages/anafpy/` — its docstrings are the spec.
|
|
71
|
+
|
|
72
|
+
## Conventions
|
|
73
|
+
|
|
74
|
+
Robert's standard Python stack applies (see the `python-conventions` skill):
|
|
75
|
+
Python 3.12+, `uv`, src layout, full type hints with `mypy --strict`,
|
|
76
|
+
Pydantic v2 for anything structured, `pydantic-settings` for env config,
|
|
77
|
+
`structlog` key-value logging, `httpx`/`tenacity` (via anafpy), `typer` CLI,
|
|
78
|
+
`pytest` with pragmatic coverage. Google-style docstrings on public surfaces.
|
|
79
|
+
|
|
80
|
+
Tests use fakes at the `EFacturaClient` seam (`tests/test_engine.py`) and
|
|
81
|
+
`model_construct` to build invoice views without full UBL validation — follow
|
|
82
|
+
those patterns rather than mocking HTTP.
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# anaf-sync — design
|
|
2
|
+
|
|
3
|
+
Why this tool is shaped the way it is. Companion to [README.md](README.md)
|
|
4
|
+
(usage) and [CLAUDE.md](CLAUDE.md) (working conventions).
|
|
5
|
+
|
|
6
|
+
## 1. Problem and goals
|
|
7
|
+
|
|
8
|
+
ANAF's SPV purges e-Factura messages roughly **60 days** after filing. Any
|
|
9
|
+
business that wants a durable local archive must poll within that window and
|
|
10
|
+
keep its own copy. The goals, in order:
|
|
11
|
+
|
|
12
|
+
1. **Never lose an invoice.** Anything filed must land on disk before ANAF
|
|
13
|
+
purges it, even across crashes, reboots, and flaky networks.
|
|
14
|
+
2. **Zero-attention operation.** Install once, schedule, forget. A run that
|
|
15
|
+
finds nothing new is silent and cheap.
|
|
16
|
+
3. **Human-shaped archive.** The on-disk layout is the user's to define, from
|
|
17
|
+
invoice data (`2026/07/2026-07-03_FCT-1001_ACME SRL.xml`), not ANAF's
|
|
18
|
+
opaque message ids.
|
|
19
|
+
4. **Windows and Linux first-class** (macOS comes along for free — it is the
|
|
20
|
+
development machine).
|
|
21
|
+
|
|
22
|
+
Non-goals: uploading/filing invoices, a GUI, multi-tenant server operation,
|
|
23
|
+
long-term document management (search, OCR, bookkeeping integration). The
|
|
24
|
+
archive is plain files; downstream tools take it from there.
|
|
25
|
+
|
|
26
|
+
## 2. Position in the anafpy ecosystem
|
|
27
|
+
|
|
28
|
+
anaf-sync is a *consumer* of anafpy, not a fork of its concerns. The split:
|
|
29
|
+
|
|
30
|
+
- **anafpy** owns everything ANAF-shaped: OAuth + token refresh, transport,
|
|
31
|
+
pagination, response parsing, UBL models, the 60-day window rules.
|
|
32
|
+
- **anaf-sync** owns everything archive-shaped: what to keep, where to put
|
|
33
|
+
it, what has already been fetched, and when to run.
|
|
34
|
+
|
|
35
|
+
The most consequential decision follows from this: **anaf-sync has no
|
|
36
|
+
credential system of its own.** It reads the same `ANAFPY_CLIENT_ID` /
|
|
37
|
+
`ANAFPY_CLIENT_SECRET` env vars and the same token store
|
|
38
|
+
(`anafpy auth login`, keyring or file backend, selected by
|
|
39
|
+
`ANAFPY_TOKEN_STORE_BACKEND` / `ANAFPY_TOKEN_STORE`) as the anafpy CLI and
|
|
40
|
+
MCP server. One browser login with the ANAF certificate serves every tool;
|
|
41
|
+
`TokenProvider` re-reads the store on each use, so a refresh performed by any
|
|
42
|
+
process is picked up by the others. The scheduled job depends on refresh
|
|
43
|
+
working headlessly, which is why missing client credentials are a hard,
|
|
44
|
+
early error rather than a warning.
|
|
45
|
+
|
|
46
|
+
## 3. The sync model: stateless window, stateful archive
|
|
47
|
+
|
|
48
|
+
Each run lists the **full lookback window** (default the whole 60 days) and
|
|
49
|
+
dedupes against a local state file, rather than tracking a "last synced"
|
|
50
|
+
timestamp.
|
|
51
|
+
|
|
52
|
+
Rationale: a timestamp cursor is fragile in exactly the ways that lose
|
|
53
|
+
invoices — clock skew, a failed run advancing the cursor, ANAF's listing
|
|
54
|
+
being eventually-consistent at the window edge. Listing is cheap (paginated
|
|
55
|
+
JSON); downloads are the expensive part, and the state file already gates
|
|
56
|
+
those. With overlapping windows every run gets a fresh chance at anything
|
|
57
|
+
previously missed, and a message only leaves the retry pool by being archived.
|
|
58
|
+
|
|
59
|
+
Mechanics (`engine.py` + `state.py`):
|
|
60
|
+
|
|
61
|
+
- The listing is materialised first so a pagination error aborts before any
|
|
62
|
+
download work.
|
|
63
|
+
- `state.json` maps message id → record (when, where, which artifacts). It is
|
|
64
|
+
written **atomically after every archived message** (temp file + rename,
|
|
65
|
+
same pattern as anafpy's `FileTokenStore`), so a crash mid-run redoes at
|
|
66
|
+
most the in-flight message — and that redo is harmless because downloads
|
|
67
|
+
are idempotent GETs.
|
|
68
|
+
- Failures are per-message: an `AnafError` on one download is recorded in the
|
|
69
|
+
`SyncReport` and the run continues. The next scheduled run retries it
|
|
70
|
+
naturally, because it is still absent from the state file. Anything outside
|
|
71
|
+
the `AnafError` hierarchy is a bug and crashes the run loudly.
|
|
72
|
+
- Persistent failures also leave a trace in the state file (first/last attempt,
|
|
73
|
+
count, last error) so `anaf-sync status` can surface a message that keeps
|
|
74
|
+
failing before the 60-day window closes on it. These records are
|
|
75
|
+
**observability only** — they must never gate a retry; the record is cleared
|
|
76
|
+
the moment the message finally archives, and pruned once its last attempt
|
|
77
|
+
ages past `state_retention_days`.
|
|
78
|
+
- Transient transport and rate-limit errors retry in-process with
|
|
79
|
+
exponential-jitter backoff (tenacity, 4 attempts) before counting as a
|
|
80
|
+
failure. Only the idempotent download GET retries; mirroring anafpy's
|
|
81
|
+
"single transparent call" stance, nothing non-idempotent ever does.
|
|
82
|
+
- Records older than `state_retention_days` (default 90) are pruned at the
|
|
83
|
+
start of each non-dry run: past ANAF's 60-day retention a message id can
|
|
84
|
+
never be listed again, so its record gates nothing. The configured value is
|
|
85
|
+
floored at 60 for exactly that reason — pruning younger records would
|
|
86
|
+
re-download messages still in the window. This keeps `state.json` bounded
|
|
87
|
+
by the window, not by the archive's lifetime.
|
|
88
|
+
|
|
89
|
+
`--redownload` bypasses the state gate (re-fetch everything, e.g. after
|
|
90
|
+
changing the template); `--dry-run` reports what would be fetched without
|
|
91
|
+
touching disk or state.
|
|
92
|
+
|
|
93
|
+
## 4. Path templating
|
|
94
|
+
|
|
95
|
+
The archive layout is a template over per-invoice variables, e.g.
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
{cif}/{direction}/{issue_date:%Y}/{issue_date:%m}/{issue_date:%Y-%m-%d}_{number}_{partner_name}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
**Language choice: Python's `str.format` mini-language,** not Jinja2.
|
|
102
|
+
Format specs give the two things a path actually needs — variable
|
|
103
|
+
substitution and formatting (crucially `strftime` specs on real dates) — with
|
|
104
|
+
zero dependencies, a syntax users already know, and no logic (loops,
|
|
105
|
+
conditionals, filters) to escape-hatch into. If real conditional layout is
|
|
106
|
+
ever needed, that is a sign the variable set is wrong, not that the template
|
|
107
|
+
needs a `{% if %}`.
|
|
108
|
+
|
|
109
|
+
Safety properties (`template.py`), enforced at one choke point:
|
|
110
|
+
|
|
111
|
+
- Every **substituted value** is sanitised: Windows-illegal characters and
|
|
112
|
+
control chars become `-`, trailing dots/spaces are stripped. Literal `/` in
|
|
113
|
+
the template creates directories; a `/` inside a value cannot.
|
|
114
|
+
- The rendered path must be relative and contain no `..` — output can never
|
|
115
|
+
escape the configured root, whatever an invoice number contains.
|
|
116
|
+
- `None` renders as `unknown` rather than failing: an invoice that ANAF
|
|
117
|
+
accepted must be archivable even when our parser cannot project a field.
|
|
118
|
+
- Unknown variables fail fast with the full list of available names —
|
|
119
|
+
template typos surface on the first run, not as mis-filed invoices.
|
|
120
|
+
|
|
121
|
+
The variable set (`context.py`) is assembled from two tiers: the message
|
|
122
|
+
listing (always present) and the parsed UBL view (best-effort). `partner_*`
|
|
123
|
+
is the deliberate star: "the other party" resolved by direction, so one
|
|
124
|
+
template serves both received and sent archives. Party CIFs prefer the
|
|
125
|
+
invoice's own VAT fields and fall back to what anafpy extracts from the
|
|
126
|
+
listing's `detalii` prose (ANAF never sends them as structured fields).
|
|
127
|
+
|
|
128
|
+
A base path collision with an *existing different* file gets a `_{message_id}`
|
|
129
|
+
suffix rather than clobbering — two invoices may legitimately render the same
|
|
130
|
+
name.
|
|
131
|
+
|
|
132
|
+
## 5. Artifacts
|
|
133
|
+
|
|
134
|
+
Per message, the user picks any of:
|
|
135
|
+
|
|
136
|
+
| artifact | content | why |
|
|
137
|
+
|---|---|---|
|
|
138
|
+
| `zip` | the raw `descarcare` ZIP | the legally meaningful, signed original — tier-1 truth, byte-preserved |
|
|
139
|
+
| `xml` | invoice UBL extracted from the ZIP | convenient for downstream parsing |
|
|
140
|
+
| `signature` | detached MF signature XML | verification without unzipping |
|
|
141
|
+
| `pdf` | ANAF's own rendering | human-readable copy, via the public no-auth `transformare` service (`validate=False` — the document already passed validation at filing) |
|
|
142
|
+
| `metadata` | JSON sidecar: listing entry + resolved context | machine-readable index without re-parsing UBL |
|
|
143
|
+
|
|
144
|
+
Default is `["zip", "pdf"]`: the archive keeps the authoritative bytes plus
|
|
145
|
+
the copy humans actually ask for. The XML stays available inside the ZIP (and
|
|
146
|
+
as an opt-in artifact) — and since the PDF is rendered *from* that XML by a
|
|
147
|
+
public no-auth service, it can be regenerated later; only the ZIP is
|
|
148
|
+
unrecoverable after ANAF's window. The PDF client is only constructed when the
|
|
149
|
+
artifact is enabled, and a non-PDF response (ANAF answers HTTP 200 with a
|
|
150
|
+
JSON error) is a logged skip, not a failure — the invoice itself is already
|
|
151
|
+
safe on disk.
|
|
152
|
+
|
|
153
|
+
## 6. Configuration split
|
|
154
|
+
|
|
155
|
+
Two layers, on purpose:
|
|
156
|
+
|
|
157
|
+
- **TOML file** (`config.toml`, platformdirs config dir) for behaviour: CIFs,
|
|
158
|
+
direction, window, output template, artifacts. Human-owned,
|
|
159
|
+
diffable, commented by `anaf-sync init`, readable with stdlib `tomllib`.
|
|
160
|
+
There is deliberately no `environment` key: ANAF's TEST inbox only ever
|
|
161
|
+
holds messages you uploaded there yourself, so an archiver pointed at it
|
|
162
|
+
syncs nothing real, and every operation we perform is a read — production
|
|
163
|
+
is always safe. `--dry-run` covers the "preview without writing" need,
|
|
164
|
+
against the real inbox.
|
|
165
|
+
- **Environment variables** for secrets and machine wiring: the `ANAFPY_*`
|
|
166
|
+
family (§2), plus `ANAF_SYNC_CONFIG` to relocate the config file. Secrets
|
|
167
|
+
never live in the TOML.
|
|
168
|
+
|
|
169
|
+
State (`state.json`) lives in the platformdirs *state* dir, separate from
|
|
170
|
+
config: wiping or versioning configuration must not forget what has been
|
|
171
|
+
archived.
|
|
172
|
+
|
|
173
|
+
## 7. Scheduling: the OS's job, not ours
|
|
174
|
+
|
|
175
|
+
`anaf-sync schedule install` registers `anaf-sync sync` with the native
|
|
176
|
+
scheduler; there is no daemon, no long-running process, no internal cron:
|
|
177
|
+
|
|
178
|
+
- **Windows** — Task Scheduler via `schtasks` (interval → `/SC MINUTE|HOURLY|DAILY /MO n`, `--daily-at` → `/SC DAILY /ST`).
|
|
179
|
+
- **Linux** — systemd **user** units (`anaf-sync.timer` + `.service`,
|
|
180
|
+
`Persistent=true` so missed runs fire on wake; `loginctl enable-linger`
|
|
181
|
+
documented for logged-out operation).
|
|
182
|
+
- **macOS** — a launchd agent (`StartInterval` / `StartCalendarInterval`).
|
|
183
|
+
|
|
184
|
+
Rationale: native schedulers survive reboots, handle wake-from-sleep and
|
|
185
|
+
missed windows, and are inspectable with tools operators already know. The
|
|
186
|
+
CLI resolves its own console-script path at install time so the job works
|
|
187
|
+
without any venv activation. Because runs are idempotent (§3), overlapping
|
|
188
|
+
or missed schedules are harmless — the schedule needs to be *roughly* right,
|
|
189
|
+
never precise.
|
|
190
|
+
|
|
191
|
+
## 8. Error handling and observability
|
|
192
|
+
|
|
193
|
+
Mirrors anafpy's hybrid model:
|
|
194
|
+
|
|
195
|
+
- **Values for business outcomes**: the `SyncReport` (listed / new / already
|
|
196
|
+
archived / non-invoice / failures) is the result of a run; per-message
|
|
197
|
+
failures are data in it.
|
|
198
|
+
- **Exceptions for broken preconditions**: missing config, missing
|
|
199
|
+
credentials, invalid template, unexpected response shapes. These propagate
|
|
200
|
+
to the CLI boundary, which is the only place they are formatted for humans
|
|
201
|
+
and turned into exit codes (non-zero when anything failed, so the OS
|
|
202
|
+
scheduler's failure status is meaningful).
|
|
203
|
+
- `structlog` key-value logging throughout (`archived`,
|
|
204
|
+
`message_id=…, path=…`), console-rendered; `--verbose` for debug.
|
|
205
|
+
|
|
206
|
+
## 9. Known trade-offs and future work
|
|
207
|
+
|
|
208
|
+
- **Sequential downloads.** Deliberate: ANAF enforces daily call quotas and
|
|
209
|
+
rate limits, and a nightly batch is not latency-sensitive. Concurrency is
|
|
210
|
+
the first knob to turn if volumes ever demand it.
|
|
211
|
+
- **State is a JSON file.** Right up to tens of thousands of messages; SQLite
|
|
212
|
+
is the successor if listing volumes or query needs grow.
|
|
213
|
+
- **Purge awareness, not purge alerts.** A message that fails for 60 days
|
|
214
|
+
straight ages out of ANAF's window and is lost. Failures are visible in
|
|
215
|
+
every run's report and exit code; an explicit "about to age out" warning
|
|
216
|
+
would be a cheap, worthwhile addition.
|
|
217
|
+
- **No archive verification command.** `anaf-sync verify` (re-hash artifacts
|
|
218
|
+
against state, validate MF signatures via `validate_signature`) is a
|
|
219
|
+
natural extension.
|
anaf_sync-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: anaf-sync
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Scheduled local archiver for RO e-Factura invoices, built on anafpy.
|
|
5
|
+
Author-email: Robert Malai <rmalai@trocglobal.com>
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Requires-Dist: anafpy>=0.4.0
|
|
8
|
+
Requires-Dist: platformdirs>=4.2
|
|
9
|
+
Requires-Dist: pydantic-settings>=2.3
|
|
10
|
+
Requires-Dist: pydantic>=2.7
|
|
11
|
+
Requires-Dist: structlog>=24.1
|
|
12
|
+
Requires-Dist: typer>=0.12
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# anaf-sync
|
|
16
|
+
|
|
17
|
+
<p>
|
|
18
|
+
<a href="https://github.com/robert-malai/anaf-sync/actions/workflows/ci.yml"><img
|
|
19
|
+
src="https://img.shields.io/github/actions/workflow/status/robert-malai/anaf-sync/ci.yml?branch=main&label=CI" alt="CI"></a>
|
|
20
|
+
<a href="https://codecov.io/gh/robert-malai/anaf-sync"><img
|
|
21
|
+
src="https://img.shields.io/codecov/c/github/robert-malai/anaf-sync?branch=main" alt="Coverage"></a>
|
|
22
|
+
<a href="https://pypi.org/project/anaf-sync/"><img
|
|
23
|
+
src="https://img.shields.io/pypi/v/anaf-sync" alt="PyPI version"></a>
|
|
24
|
+
<a href="https://pypi.org/project/anaf-sync/"><img
|
|
25
|
+
src="https://img.shields.io/pypi/pyversions/anaf-sync" alt="Python versions"></a>
|
|
26
|
+
</p>
|
|
27
|
+
|
|
28
|
+
Scheduled local archiver for RO e-Factura invoices, built on
|
|
29
|
+
[anafpy](https://github.com/robert-malai/anafpy). Each run lists every message
|
|
30
|
+
in ANAF's 60-day retention window, downloads the ones it hasn't seen before,
|
|
31
|
+
and files them under paths rendered from a template of invoice variables.
|
|
32
|
+
Runs on Windows, Linux, and macOS.
|
|
33
|
+
|
|
34
|
+
Design rationale lives in [DESIGN.md](DESIGN.md); conventions for working on
|
|
35
|
+
the codebase in [CLAUDE.md](CLAUDE.md).
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
uv tool install anaf-sync # from a published wheel
|
|
41
|
+
# or, from this checkout:
|
|
42
|
+
uv tool install --from . anaf-sync
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Authentication
|
|
46
|
+
|
|
47
|
+
anaf-sync reuses the anafpy login — the same one the anafpy MCP server uses:
|
|
48
|
+
|
|
49
|
+
1. `anafpy auth login` (browser OAuth with your ANAF certificate) writes the
|
|
50
|
+
token set to the OS credential store (or a JSON file).
|
|
51
|
+
2. Set `ANAFPY_CLIENT_ID` and `ANAFPY_CLIENT_SECRET` in the environment (or a
|
|
52
|
+
`.env` file) so scheduled runs can refresh expired tokens.
|
|
53
|
+
|
|
54
|
+
Optional, mirroring anafpy: `ANAFPY_TOKEN_STORE_BACKEND=file` and
|
|
55
|
+
`ANAFPY_TOKEN_STORE=~/.anafpy/tokens.json` for headless hosts without a
|
|
56
|
+
credential store.
|
|
57
|
+
|
|
58
|
+
## Configure
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
anaf-sync init # writes a commented config.toml
|
|
62
|
+
anaf-sync status # shows where it lives on your platform
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The interesting part is the path template:
|
|
66
|
+
|
|
67
|
+
```toml
|
|
68
|
+
[output]
|
|
69
|
+
directory = "~/Facturi"
|
|
70
|
+
template = "{cif}/{direction}/{issue_date:%Y}/{issue_date:%m}/{issue_date:%Y-%m-%d}_{number}_{partner_name}"
|
|
71
|
+
artifacts = ["zip", "pdf"] # also: xml, signature, metadata
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Templates use Python format syntax over the invoice's context: `number`,
|
|
75
|
+
`issue_date` / `due_date` (real dates — strftime specs work), `currency`,
|
|
76
|
+
`total`, `kind`, `direction`, `cif`, `seller_name`/`seller_cif`,
|
|
77
|
+
`buyer_name`/`buyer_cif`, `partner_name`/`partner_cif` (the *other* party),
|
|
78
|
+
`message_id`, `request_id`, `message_type`, `created`. Substituted values are
|
|
79
|
+
sanitised for the filesystem; literal `/` in the template creates folders; each
|
|
80
|
+
artifact appends its own extension.
|
|
81
|
+
|
|
82
|
+
## Run
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
anaf-sync sync --dry-run # see what would be downloaded
|
|
86
|
+
anaf-sync sync # download everything new
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Runs are idempotent: a state file records archived message ids, so overlapping
|
|
90
|
+
60-day windows never duplicate work, and anything ANAF is about to purge has
|
|
91
|
+
already been captured.
|
|
92
|
+
|
|
93
|
+
## Schedule
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
anaf-sync schedule install --every 6h # or --daily-at 07:30
|
|
97
|
+
anaf-sync schedule status
|
|
98
|
+
anaf-sync schedule remove
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
This registers the sync with the OS scheduler — Task Scheduler on Windows,
|
|
102
|
+
a systemd user timer on Linux (`loginctl enable-linger $USER` to run while
|
|
103
|
+
logged out), launchd on macOS. No daemon of its own.
|
|
104
|
+
|
|
105
|
+
## Development
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
uv sync
|
|
109
|
+
uv run pytest
|
|
110
|
+
uv run ruff check src tests && uv run black --check src tests && uv run mypy src
|
|
111
|
+
```
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# anaf-sync
|
|
2
|
+
|
|
3
|
+
<p>
|
|
4
|
+
<a href="https://github.com/robert-malai/anaf-sync/actions/workflows/ci.yml"><img
|
|
5
|
+
src="https://img.shields.io/github/actions/workflow/status/robert-malai/anaf-sync/ci.yml?branch=main&label=CI" alt="CI"></a>
|
|
6
|
+
<a href="https://codecov.io/gh/robert-malai/anaf-sync"><img
|
|
7
|
+
src="https://img.shields.io/codecov/c/github/robert-malai/anaf-sync?branch=main" alt="Coverage"></a>
|
|
8
|
+
<a href="https://pypi.org/project/anaf-sync/"><img
|
|
9
|
+
src="https://img.shields.io/pypi/v/anaf-sync" alt="PyPI version"></a>
|
|
10
|
+
<a href="https://pypi.org/project/anaf-sync/"><img
|
|
11
|
+
src="https://img.shields.io/pypi/pyversions/anaf-sync" alt="Python versions"></a>
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
Scheduled local archiver for RO e-Factura invoices, built on
|
|
15
|
+
[anafpy](https://github.com/robert-malai/anafpy). Each run lists every message
|
|
16
|
+
in ANAF's 60-day retention window, downloads the ones it hasn't seen before,
|
|
17
|
+
and files them under paths rendered from a template of invoice variables.
|
|
18
|
+
Runs on Windows, Linux, and macOS.
|
|
19
|
+
|
|
20
|
+
Design rationale lives in [DESIGN.md](DESIGN.md); conventions for working on
|
|
21
|
+
the codebase in [CLAUDE.md](CLAUDE.md).
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
uv tool install anaf-sync # from a published wheel
|
|
27
|
+
# or, from this checkout:
|
|
28
|
+
uv tool install --from . anaf-sync
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Authentication
|
|
32
|
+
|
|
33
|
+
anaf-sync reuses the anafpy login — the same one the anafpy MCP server uses:
|
|
34
|
+
|
|
35
|
+
1. `anafpy auth login` (browser OAuth with your ANAF certificate) writes the
|
|
36
|
+
token set to the OS credential store (or a JSON file).
|
|
37
|
+
2. Set `ANAFPY_CLIENT_ID` and `ANAFPY_CLIENT_SECRET` in the environment (or a
|
|
38
|
+
`.env` file) so scheduled runs can refresh expired tokens.
|
|
39
|
+
|
|
40
|
+
Optional, mirroring anafpy: `ANAFPY_TOKEN_STORE_BACKEND=file` and
|
|
41
|
+
`ANAFPY_TOKEN_STORE=~/.anafpy/tokens.json` for headless hosts without a
|
|
42
|
+
credential store.
|
|
43
|
+
|
|
44
|
+
## Configure
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
anaf-sync init # writes a commented config.toml
|
|
48
|
+
anaf-sync status # shows where it lives on your platform
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The interesting part is the path template:
|
|
52
|
+
|
|
53
|
+
```toml
|
|
54
|
+
[output]
|
|
55
|
+
directory = "~/Facturi"
|
|
56
|
+
template = "{cif}/{direction}/{issue_date:%Y}/{issue_date:%m}/{issue_date:%Y-%m-%d}_{number}_{partner_name}"
|
|
57
|
+
artifacts = ["zip", "pdf"] # also: xml, signature, metadata
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Templates use Python format syntax over the invoice's context: `number`,
|
|
61
|
+
`issue_date` / `due_date` (real dates — strftime specs work), `currency`,
|
|
62
|
+
`total`, `kind`, `direction`, `cif`, `seller_name`/`seller_cif`,
|
|
63
|
+
`buyer_name`/`buyer_cif`, `partner_name`/`partner_cif` (the *other* party),
|
|
64
|
+
`message_id`, `request_id`, `message_type`, `created`. Substituted values are
|
|
65
|
+
sanitised for the filesystem; literal `/` in the template creates folders; each
|
|
66
|
+
artifact appends its own extension.
|
|
67
|
+
|
|
68
|
+
## Run
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
anaf-sync sync --dry-run # see what would be downloaded
|
|
72
|
+
anaf-sync sync # download everything new
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Runs are idempotent: a state file records archived message ids, so overlapping
|
|
76
|
+
60-day windows never duplicate work, and anything ANAF is about to purge has
|
|
77
|
+
already been captured.
|
|
78
|
+
|
|
79
|
+
## Schedule
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
anaf-sync schedule install --every 6h # or --daily-at 07:30
|
|
83
|
+
anaf-sync schedule status
|
|
84
|
+
anaf-sync schedule remove
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
This registers the sync with the OS scheduler — Task Scheduler on Windows,
|
|
88
|
+
a systemd user timer on Linux (`loginctl enable-linger $USER` to run while
|
|
89
|
+
logged out), launchd on macOS. No daemon of its own.
|
|
90
|
+
|
|
91
|
+
## Development
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
uv sync
|
|
95
|
+
uv run pytest
|
|
96
|
+
uv run ruff check src tests && uv run black --check src tests && uv run mypy src
|
|
97
|
+
```
|