Tax declarations (authoring, validation, signing, status)¶
anafpy.declaratii prepares Romanian tax declarations (D300 VAT return first;
the design is per-form generic) entirely locally: it validates with ANAF's
own DUKIntegrator, renders the official PDF, and signs it with the taxpayer's
qualified certificate. Filing the signed PDF works two ways: manually on the
portal, or through the
upload client — live-verified end to end on
2026-07-17. Afterwards you track it from here:
DeclarationStatusClient
checks the processing status and downloads the signed recipisa over ANAF's
public StareD112 service, with no login of any kind.
The pipeline: unstructured info → author the XML → DUKIntegrator -v (validate
in a loop until ok) → DUKIntegrator -p (official PDF) → pyHanko + the
platform raw signer (qualified signature) → signed PDF on disk → manual portal
upload → status/recipisa via StareD112.
Prerequisites¶
- DUKIntegrator — download
dist_javaInclus20200203.zip, extract it, and drop the per-form validator jars (e.g.D300Validator.jar,D300Pdf.jarfrom ANAF's update feed) intodist/lib/. Ignore the bundled 32-bit JRE 6 — any modern JVM works. - A JRE/JDK (Java 8+) on
PATH, or setANAFPY_DUK_JAVA. - The
declaratiiextra for signing:pip install 'anafpy[declaratii]'(pyHanko). Validation and rendering do not need it.
See the DUKIntegrator reference for the
CLI contract, the err-file format, and the nr_evid layout.
Validate and render¶
from pathlib import Path
from anafpy.declaratii import DukIntegrator
duk = DukIntegrator(Path("~/DUKIntegrator/dist").expanduser())
xml = Path("d300.xml").read_bytes()
result = await duk.validate("D300", xml)
if not result.ok:
for finding in result.findings: # DUK's own messages, verbatim
print(finding.severity, finding.message)
# Render the official PDF (validates first; writes nothing on failure).
rendered = await duk.render("D300", xml, Path("d300.pdf"))
assert rendered.ok
validate/render judge success by the err-file content, never by the exit
code (DUK exits 0 on a validation failure). installed_forms() and
fetch_feed_versions() power a staleness check — the feed works before DUK is
installed, and CLI-mode DUK does not auto-update, so an installed validator can
lag ANAF's current one.
The nr_evid helper¶
D300 requires nr_evid, a 23-character payment-evidence number with a check
digit. Compute it — never by hand:
from anafpy.declaratii import payment_evidence_number
payment_evidence_number(tip_decont="L", month=6, year=2026)
# '10301010626250726000042'
The self-assessed siblings have their own composers, exported alongside:
obligation_evidence_number (D100/D710 — the obligation code in the code slot,
an explicit due date), profit_tax_evidence_number (D101 — prefix 11, a
liquidation flag), and special_vat_evidence_number (D301 — the new-means-of-
transport flag).
Signing (macOS)¶
anafpy never touches key material: the raw RSA signature is delegated to the OS,
and the token middleware owns the PIN/2FA. On macOS the qualified certificate
lives behind a CryptoTokenKit extension (no PKCS#11 dylib), so the key is reached
through Security.framework via KeychainRawSigner; each signature fires the
token's approval prompt.
from anafpy.declaratii import KeychainRawSigner, load_pdfsign
from anafpy.declaratii.signing import resolve_signing_label
pdfsign = load_pdfsign() # clear install hint if the extra is absent
label = resolve_signing_label() # ANAFPY_SIGN_IDENTITY / persisted SPV cert
signer = KeychainRawSigner(label) # same qualified certificate as SPV
result = await pdfsign.sign_pdf(Path("d300.pdf").read_bytes(), signer)
Path("d300-semnat.pdf").write_bytes(result.pdf)
pdfsign.sign_pdf embeds a standard adbe.pkcs7.detached CMS as an incremental update,
so a rendered PDF's embedded XML (/EmbeddedFiles) survives and the signature
covers the whole file. The leaf's direct issuer certificate is fetched
best-effort from its AIA URL; DER, PEM, and PKCS#7 responses are validated and
cached as DER only. result.chain_complete means exactly that this one issuer
is embedded — deeper intermediates are not chased. If fetching or parsing fails
the CMS is leaf-only and result.chain_complete is False (portal acceptance
of a leaf-only chain is unverified). The identity defaults to the persisted SPV certificate selection
(anafpy spv select) — the same qualified certificate — or set
ANAFPY_SIGN_IDENTITY.
Windows signing is not in this release; the RawSigner protocol is the seam
a CngRawSigner will slot into.
Filing on the portal¶
DeclarationUploadClient automates the "Depunere declarații" portal
(decl.anaf.mfinante.gov.ro/WAS6DUS): a certificate login — same
platform-keystore model as the SPV client,
fires the token PIN / 2FA — followed by the one multipart POST of the
signed PDF. Live-verified end to end (2026-07-17, a D406T filing): the success
page yields the upload index, the portal's known rejection page comes back
as accepted=False with the reason, and an unrecognised page returns
accepted=None with the raw html carried. Mind the portal's own caveat: the
success page is not the registration confirmation — that is the recipisa,
which you poll via StareD112 with the returned index. Sessions are disposable
(the portal enforces a ~10-minute inactivity timeout): log in, upload, done.
from anafpy.declaratii import DeclarationUploadClient, PortalCurlBootstrapper
async with DeclarationUploadClient(
bootstrapper=PortalCurlBootstrapper("MY CERT IDENTITY") # Keychain name
) as client:
await client.login() # fires the certificate 2FA
result = await client.upload(signed_pdf, filename="d300.pdf")
if result.accepted:
print("upload index:", result.upload_index) # feed it to StareD112
elif result.accepted is False:
print("rejected:", result.reason)
There is no TEST environment for declaration filing — every upload is a production filing. The one sanctioned no-effect exercise is D406T, the SAF-T voluntary-testing declaration (no legal or fiscal effect; see the portal-upload reference) — which is exactly what anafpy's own gated live test files.
Filing status and recipisa¶
After you upload the signed PDF on the portal, ANAF hands back an upload index (also the recipisa number). ANAF's StareD112 service is public and unauthenticated — the index + CUI pair is the access key — so checking the processing status and fetching the signed recipisa needs no certificate, no OAuth, nothing:
from anafpy.declaratii import DeclarationStatusClient
async with DeclarationStatusClient() as client:
status = await client.check_status(1100000001, "99999909")
if status.found:
for doc in status.documents: # ALL the CUI's filings, last 3 months
print(doc.index, doc.form, doc.state, doc.upload_date)
mine = status.document(1100000001) # the row for the queried index
pdf = await client.download_receipt(1100000001)
if pdf is not None: # None: unknown index or window lapsed
Path("recipisa.pdf").write_bytes(pdf)
Each document's state is a DeclarationState — an enum whose values are
ANAF's verbatim Romanian wording ("Documentul este valid",
"In prelucrare", …) with a one-line English description per member, like
the SPV ReportType nomenclature. Compare by member
(doc.state is DeclarationState.VALID); the text as actually served stays in
state_text. Keep polling while a document is DeclarationState.PROCESSING.
For documents filed at an ANAF
counter, pass filed_at_counter=True and give the registration number as the
index. Counter registration numbers are passed through after whitespace
trimming (dash/slash formats are accepted); internet upload indexes remain
digits-only. Service limits (ANAF's, not anafpy's): only the last 3 months / last
200 submissions are queryable, and the recipisa PDF is available for
~60 days from filing (receipt_available per row) — archive it promptly;
it is the digitally signed proof of filing.
CLI¶
anafpy declaratii validate D300 d300.xml --duk-dir ~/DUKIntegrator/dist
anafpy declaratii render D300 d300.xml -o d300.pdf --duk-dir ~/DUKIntegrator/dist
anafpy declaratii sign d300.pdf -o d300-semnat.pdf # fires the PIN/2FA prompt
anafpy declaratii status 1100000001 99999909 # public — no login
anafpy declaratii recipisa 1100000001 -o recipisa.pdf # public — no login
--duk-dir defaults to ANAFPY_DUK_DIR, --java to ANAFPY_DUK_JAVA. sign
resolves the certificate from --identity, then ANAFPY_SIGN_IDENTITY, then the
persisted SPV selection.
Using it through Claude (MCP)¶
The same operations are MCP tools (declaratie_validate, declaratie_render,
declaratie_sign, declaratie_nr_evid, declaratie_duk_status,
declaratie_status, declaratie_recipisa) and a declaratie-prepare skill —
see the MCP tools and setup pages.
Missing DUK/Java configuration is a tool error for validate/render, distinct
from a real DUK verdict with ok=false. Status/config/network failures use the
typed status shape (found=false plus message) rather than an unstructured
tool exception.