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.
@@ -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,14 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+
12
+ # Coverage
13
+ .coverage
14
+ coverage.xml
@@ -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.
@@ -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
+ ```