Skip to content

Tax declarations (authoring, validation, signing, status)

anafpy.declaratii prepares Romanian tax declarations (per-form generic — any form DUKIntegrator validates, from the D300 VAT return to D406/SAF-T; the declaration reference carries completion guides for the common SME forms) 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 → portal upload (the upload client, or manually) → status/recipisa via StareD112.

Prerequisites

  • DUKIntegrator — provisioned by anafpy itself: anafpy duk install assembles the managed dist at ~/.anafpy/duk-dist from ANAF's official update feed (core, config, and the common forms' validators; add rarer ones with anafpy duk install <FORM>, refresh everything with anafpy duk update). Programmatically that is await DukInstaller().install(). A hand-assembled dist/ folder keeps working — pass its path to DukIntegrator (or set ANAFPY_DUK_DIR) and it wins over the managed install.
  • A JRE/JDK (Java 8+) — on PATH, via JAVA_HOME, or set ANAFPY_DUK_JAVA.
  • The declaratii extra 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, default_duk_dir

duk = DukIntegrator(default_duk_dir())  # the managed dist; or pass a dist/ path

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. installed_forms() reads the version the installer recorded in the dist manifest, falling back to the sibling <form>IstoriaVersiunilor.txt for a hand-assembled dist, so its values compare directly against the feed's.

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, Windows)

anafpy never touches key material: the raw RSA signature is delegated to the OS, and the token middleware owns the PIN/2FA — each signature fires the token's approval prompt. platform_raw_signer picks the implementation, so calling code is the same on both platforms:

  • macOS — the qualified certificate lives behind a CryptoTokenKit extension (no PKCS#11 dylib), so the key is reached through Security.framework via KeychainRawSigner. The selector is the Keychain identity name.
  • WindowsWindowsStoreRawSigner signs through Cert:\CurrentUser\My, the same store SPV uses. The selector is the certificate's SHA-1 thumbprint.
from anafpy.declaratii import load_pdfsign, platform_raw_signer
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 = platform_raw_signer(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 to the Keychain name (macOS) or the thumbprint (Windows). A selection made on the other platform is ignored rather than mistranslated, so a home directory synced across machines cannot pick the wrong selector.

The Windows path is not yet live-verified on a real certificate store; the macOS one is (a D406T filed 2026-07-17 was accepted by the portal).

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 duk install                       # provision the managed DUKIntegrator
anafpy duk install D208                  # add a rarer form's validator
anafpy duk update                        # refresh core + installed validators

anafpy declaratii validate D300 d300.xml
anafpy declaratii render   D300 d300.xml -o d300.pdf
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, falling back to the managed install; --java defaults 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_install, 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.