Skip to main content

Portugal — AT (ATCUD + SAF-T PT)

Research doc for the Portugal fiscalization adapter. Internal reference for engineers maintaining or extending rust/adapters/portugal/.

Status: Implemented (legacy Phase-0 wedge). Adapter at rust/adapters/portugal/ (crate portugal). Strategic context: Portugal was one of the four legacy launch countries (FR/IT/PT/ES). The Portuguese regime is a hybrid: per-receipt local computation (ATCUD validation code, signed hash, QR string) with no real-time authority round-trip, plus a monthly SAF-T PT batch file submitted to AT (Autoridade Tributária e Aduaneira). Software itself must be certified by AT — the certification number prints on every receipt. This combination lets us deploy in Cloud or Embedded mode but adds a software-certification cost (~€5–10K, 4–6 months) on top of merchant onboarding. See apps/docs-internal/docs/architecture/portugal-adapter.md for the narrative architecture and apps/docs-internal/docs/ops/country-prioritization.md for the business framing.


1. Regulatory scope

What must be reported

Two parallel obligations:

ObligationMechanismFrequency
Per-receipt fiscalizationEach invoice/receipt carries a locally-computed ATCUD code, a hash extract, and a delimited-string QR. Printed on the document.Real-time at issuance
Monthly SAF-T PTXML batch (SAF-T PT v1.04_01) covering all sales documents for the period, submitted via AT's e-fatura portalMonthly, by day 5 of the following month

The legal basis combines:

  • Decreto-Lei 28/2019 — establishes the obligation to use AT-certified software for any taxpayer issuing invoices in Portugal.
  • Portaria 195/2020 — defines ATCUD format, QR-code format (Annex), and series-validation flow.
  • Portaria 302/2016 (amended) — defines the SAF-T PT v1.04_01 schema.

Who must report

Per Decreto-Lei 28/2019 Art. 2:

  • Any taxable person (sujeito passivo) whose annual turnover exceeds €50,000 must use AT-certified billing software.
  • Any taxable person, regardless of turnover, who issues invoices using software (i.e., not handwritten).
  • This effectively captures all merchants with any meaningful invoicing volume.

Exemptions

  • Below-€50,000 manual-paper-only operations remain technically permitted; these don't use software at all and are outside our reach.
  • Specific carve-outs (regime simplificado for some agricultural flat-rate suppliers).

Mandatory dates

  • 2008-01-01: Initial software-certification regime in force.
  • 2017-07-01: Mandatory QR codes deferred — eventually came in with Portaria 195/2020.
  • 2020-04-04: Portaria 195/2020 published — defines ATCUD + QR.
  • 2022-01-01: ATCUD becomes mandatory on all printed invoices/receipts (after multiple deferrals from the original 2021-01-01 date).
  • 2023-01-01: QR code becomes mandatory on all printed invoices/receipts.

The combined ATCUD+QR mandate is fully in force as of 2026-04.

Document types in scope

Mapped at rust/adapters/portugal/src/config.rs:101-114:

pub fn resolve_series_type(tx_type: &str, amount: i64, buyer_nif: &str) -> &'static str {
match tx_type {
"refund" => "NC",
"adjustment" => "ND",
"sale" => {
if is_b2b(buyer_nif) || amount > SIMPLIFIED_INVOICE_THRESHOLD {
"FT"
} else {
"FS"
}
}
_ => "FT",
}
}

SIMPLIFIED_INVOICE_THRESHOLD = 10000 minor units (€100, config.rs:7).

CodeNameWhen
FTFaturaB2B (buyer NIF present) or B2C amount > €100
FSFatura SimplificadaB2C and amount ≤ €100
FRFatura-Reciboinvoice + payment receipt in one (configurable per series)
NCNota de Créditorefund
NDNota de Débitoadjustment / underbill

The "final consumer" placeholder NIF is 999999990 (adapter.rs:8, qr.rs:8).


2. API surface

Per-receipt: no real-time API

There is no synchronous AT call per receipt. The adapter computes the ATCUD, hash, and QR locally; the receipt is then printed and the transaction is complete from a real-time perspective.

This is reflected in adapter.rs:255-264:

fn submission_window(&self) -> SubmissionWindow {
// Portugal: per-transaction ATCUD/QR locally. No real-time submission.
// Monthly SAF-T is a separate batch flow, not part of this interface.
SubmissionWindow::None
}
fn supported_deployment_modes(&self) -> &[DeploymentMode] {
&[DeploymentMode::Cloud, DeploymentMode::EmbeddedLocal]
}

Both deployment modes work because the per-receipt artefacts (ATCUD, hash, QR) are computable from (NIF, doc_id, ATCUD, amount) — nothing requires hardware-bound key material at the per-receipt boundary.

Out-of-band: series registration with AT

Each document series (one per (taxpayer NIF, document type)) must be registered once with AT through the e-fatura portal. AT returns a validation_code (8+ alphanumeric chars; the atcud_code prefix) unique to that series. The code is provisioned via the AT portal — not via our adapter — and stored in Config.series[doc_type].atcud_code (config.rs:33-37):

pub struct SeriesConfig {
pub prefix: String, // e.g. "FT 2026/"
pub atcud_code: String, // e.g. "CSDF7T5H"
}

Monthly SAF-T submission

The SAF-T PT XML is built by saft::build_saft_xml (saft.rs:67-78) and submitted via AT's webservice / portal. The submission endpoint itself is not wired into the adapter today — the adapter only emits the XML. Submission is a downstream concern (cron job + a separate AT-portal client).

The schema namespace (saft.rs:8-11): urn:OECD:StandardAuditFile-Tax:PT_1.04_01. Version 1.04_01.

AT mock service for testing

The architecture doc references a MockATServer exposing:

  • POST /saft/submit — SAF-T submission.
  • POST /atcud/register-series — series registration.
  • POST /atcud/validate — ATCUD validation.

These are the legacy Go implementation's endpoints. The Rust adapter's tests use file-based goldens (testdata/conformance/portugal/at/golden/) rather than a running mock server.


3. Authentication

Per-receipt: no authority auth

Nothing to authenticate against — the per-receipt path is local.

Software certification (one-time)

Every Portuguese fiscalization product must be AT-certified. The certification number is a 4-digit string assigned by AT to a specific release of the software. We pass it on every receipt (adapter.rs:218):

cert_number: cfg.software_certificate_number.clone(),

It surfaces in the QR as field R (qr.rs:67-69) and on the printed receipt header (mandatory under Portaria 195/2020 §6).

The cert number is per software release, not per merchant tenant. Renewal happens every release; AT requires re-certification when the fiscal pipeline changes materially.

SAF-T submission: mTLS

For the monthly SAF-T submission AT requires mutual TLS with a certificate issued by AT (or via the Portal das Finanças PKI). This is referenced in the location config as at_certificate_id (config.rs:43-44):

#[serde(default)]
pub at_certificate_id: Option<String>,

Surfaced through CountryAdapter::required_certificate_id (adapter.rs:361-368), which the orchestrator uses to pre-flight-check that the cert is present before scheduling a submission.

Document signing chain (RSA-4096) — design vs current state

The Portuguese spec mandates an RSA-2048 minimum (typically RSA-4096) document-signing chain: each invoice's hash incorporates the previous invoice's hash, signed with the merchant's RSA key. The chain produces the 4-character hash extract that prints on the receipt and goes into the QR's Q field.

Current adapter implementation (adapter.rs:185-189):

let hash_input = format!("{};{};{};{}", loc.tax_id, doc_id, atcud, tx.amount);
let hash = Sha256::digest(hash_input.as_bytes());
let hash_hex = hex::encode(hash);
let hash_chars = &hash_hex[..4];

This is a simplified hash — SHA-256 over a 4-field string, without RSA signing and without a chain reference. The architecture doc (portugal-adapter.md) explicitly notes this is "planned for production AT certification". For sandbox and goldens this passes; for production the real RSA chain (signing the previous hash + this document's content) needs to be added before AT will certify the software for live use.


4. Payload shape

Per-receipt: 3 artefacts

For each transaction the adapter produces a PreparedRecord with no submission payload but rich receipt-rendering data (adapter.rs:265-345).

4.1 ATCUD

Format (atcud.rs:10-12):

pub fn format_atcud(validation_series: &str, sequential_number: i64) -> String {
format!("{}-{}", validation_series, sequential_number)
}

Example: CSDF7T5H-35.

The validation series (left side) is the AT-issued 8+ char alphanumeric code per (NIF, doc_type) series. The right side is the gap-free monotonic sequence within that series.

ATCUD prints on every receipt (mandatory header field per Portaria 195/2020 §6) and goes into the QR's H field.

4.2 Document hash extract (Q field)

Computed at adapter.rs:185-189 (see §3 — currently simplified). The 4 hex chars of the SHA-256 digest go into:

  • The receipt body (typically next to "Hash:" label).
  • The QR's Q field.

In production this should be the first 4 chars of SIGN(prev_hash + current_record_serialized, RSA private key), base64-encoded — that is, the spec wants Q to anchor a chain. Today's adapter computes a non-chained SHA-256 and uses the first 4 hex chars; this is enough for sandbox conformance but not for production AT cert.

4.3 QR string (Portaria 195/2020 Annex)

Built by qr::generate_qr_content (qr.rs:12-72):

A:<seller NIF>*B:<buyer NIF>*C:<buyer country>*D:<doc type>*E:<status>*F:<doc date>*G:<doc id>*H:<ATCUD>*I1:PT*[I2-I8 tax breakdown]*N:<total tax>*O:<gross total>*[Q:<hash chars>]*[R:<cert number>]

Field reference:

FieldMeaningNotes
Aseller NIFmandatory
Bbuyer NIF999999990 for final consumer
Cbuyer countryISO 3166-1 alpha-2; default PT
Ddocument typeFT / FS / NC / ND
Edocument statusN (normal) / A (cancelled)
Fissue dateYYYYMMDD
Gdocument IDseries prefix + sequential
HATCUD<validation>-<seq>
I1tax country regionPT for mainland, PT-AC Açores, PT-MA Madeira
I2exempt taxable baseonly if non-zero
I3, I4reduced base, reduced tax6% mainland (or regional variants)
I5, I6intermediate base, tax13% mainland
I7, I8normal base, tax23% mainland
Ntotal taxrequired
Ogross totalrequired
Qhash chars (first 4)optional but printed in practice
Rsoftware certificate numberrequired

Important: the QR is a delimited string, not a URL. The PreparedRecord.render_hints flags this explicitly (adapter.rs:320-331):

hint_items.push(RenderHintItem {
key: "qr".to_string(),
label: None,
value: qr.clone(),
placement: Placement::FooterRight,
mandatory: true,
kind: RenderKind::QrString, // not QrUrl
meta: serde_json::json!({ "min_size_mm": 30, "format": "AT-delimited" }),
});

RenderKind::QrString instructs the printer to encode the literal delimited string as the QR payload — not as a URL or as JSON.

4.4 SAF-T PT v1.04_01 (monthly batch)

Built by build_saft_xml (saft.rs:67-78). Top-level structure:

AuditFile (xmlns="urn:OECD:StandardAuditFile-Tax:PT_1.04_01")
├── Header
│ ├── AuditFileVersion=1.04_01
│ ├── CompanyID, TaxRegistrationNumber, CompanyName
│ ├── FiscalYear, StartDate, EndDate
│ ├── CurrencyCode=EUR
│ ├── SoftwareCertificateNumber
│ └── ProductID
├── MasterFiles
│ ├── Customer[]
│ ├── Product[]
│ └── TaxTable
│ └── TaxTableEntry (TaxType=IVA, region, percentage, description)
└── SourceDocuments
└── SalesInvoices
├── NumberOfEntries, TotalDebit, TotalCredit
└── Invoice[]
├── InvoiceNo, ATCUD
├── InvoiceStatus (N | A | S | R)
├── Hash (full RSA sig in production), HashControl
├── InvoiceDate, InvoiceType
├── Line[]
└── DocumentTotals (TaxPayable, NetTotal, GrossTotal)

Document statuses (saft.rs:14-15):

StatusCodeMeaning
NormalNactive
CancelledAanulado / void
Self-billingSauto-faturação
SummaryRresumo

VAT rates and codes (saft.rs:38-44):

CodeDescriptionMainlandAçores (PT-AC)Madeira (PT-MA)
REDreduced6%4%5%
INTintermediate13%9%12%
NORnormal23%16%22%
ISEexempt0%0%0%

Regional variants are emitted when tax_country_region is set to PT-AC or PT-MA rather than the default PT.


5. Ordering and sequencing

Per-series gap-free counter

The adapter mints sequence numbers via the SequenceStore trait (adapter.rs:40-76):

pub trait SequenceStore: Send + Sync {
fn next_sequence(&self, key: &str) -> Result<i64, String>;
}

The series_key is {NIF}:{doc_type} (config.rs:88-91):

pub fn series_key(nif: &str, series_type: &str) -> String {
format!("{}:{}", nif, series_type)
}

So a single taxpayer running with all 5 series gets 5 independent counters. Each call to next_sequence is required to produce a gap-free monotonic value — gaps trigger AT-302 ("ATCUD sequential number is not sequential", errors.rs:48) at SAF-T time.

The bundled InMemorySequenceStore (adapter.rs:45-76) is for tests only. Production deployments must wire a persistent store with strict serialization:

  • Cloud: Postgres with advisory locks (the legacy Go path).
  • Embedded: SQLite with BEGIN IMMEDIATE.

What about voids?

Voiding a document does not free its sequence. The next document gets the next number; the voided one stays in the chain with status A. This is captured at adapter.rs:191:

let doc_status = if tx.tx_type == "void" { "A" } else { "N" };

Within-month SAF-T ordering

SAF-T enforces a stricter property: for each (InvoiceType, Series) the invoices must appear in SystemEntryDate order, and the sequence numbers must be contiguous within the series for the period. The XML builder honors caller-supplied order; the upstream caller is responsible for sorting invoices by system_entry_date before passing them to build_saft_xml.

Idempotency

Per-receipt: deterministic from inputs, so re-running prepare() with the same (tx, loc, cfg) produces the same (ATCUD, hash, QR)but only if the sequence store has not advanced. In practice this means we should not re-call prepare() on retry; instead, persist the PreparedRecord and re-use it.

SAF-T submission: AT deduplicates on (taxpayer NIF, fiscal year, period, file hash). A retry of the exact same XML returns success on the original submission. A modified resubmission (corrections after the deadline) is a separate SAF-T file with status R (replacement).


6. Submission window and deployment mode

Submission window

SubmissionWindow::None for the per-receipt flow — there is no submission. The monthly SAF-T flow runs out-of-band on a cron schedule and is not modeled by this trait.

SAF-T deadline

By day 5 of the following month (Portaria 363/2010 as amended). Late submission is an administrative offense (€200–€10,000 per file, cited in the AT general infraction regime).

Deployment mode

Both Cloud and EmbeddedLocal are supported (adapter.rs:261-263). There is no hardware-binding constraint on the per-receipt path. The SAF-T mTLS cert can be brokered to Cloud-side KMS, so the monthly flow is also Cloud-OK.

This is one of the cleaner countries from a deployment-mode perspective — neither the per-receipt path nor the monthly batch forces us off Cloud.


7. Sandbox access

AT operates a sandbox at "AT — Ambiente de Testes" alongside the production Portal das Finanças. Onboarding:

  1. Register a test taxpayer NIF on the AT test portal. Free; no real KYC.
  2. Register one document series per type (FT, FS, NC, ND) — AT responds with a validation_code per series (the series.<type>.atcud_code config field).
  3. Request a test mTLS cert from AT for SAF-T submission. The cert is delivered as PKCS#12 with a one-time PIN; capture immediately.
  4. Pick a software certificate number — AT issues this on software-cert renewal; for sandbox, AT provides "1234" as a placeholder cert number that the test endpoints accept.

See apps/docs-internal/docs/countries/portugal-sandbox.md for the full step-by-step.

What we'll need

  • A .p12 checked into our test-secrets vault for the SAF-T mTLS cert.
  • A test NIF + 5 ATCUD validation codes (one per series we exercise).
  • A pinned software_certificate_number (config field, mandatory).
  • An .env template with: PT_AT_NIF, PT_AT_SAFT_PFX_PATH, PT_AT_SAFT_PFX_PASSWORD, PT_AT_SOFTWARE_CERT_NUMBER, plus the series.*.atcud_code configuration entries.

8. Error model

What can fail at prepare()

VariantSource (adapter.rs:14-37)Recovery
PortugalError::Config(ConfigError)bad config (missing legal name, invalid series type, missing prefix/atcud_code)reject at registration via validate_config; merchant fixes config
PortugalError::NoSequenceStoreadapter constructed without a SequenceStorewiring bug — bind the store at startup
PortugalError::Sequence(String)sequence store backend errortypically transient (DB blip) — retry
PortugalError::SeriesNotFound(String)tx maps to a doc type for which no series is configuredmerchant must register that series with AT and add it to config
PortugalError::Serialization(serde_json::Error)receipt JSON serializationshould not happen

These map to AdapterError::Config / AdapterError::Internal (adapter.rs:30-37).

Validation in validate_config

Per config.rs:49-85:

  • legal_name non-empty.
  • series non-empty.
  • For every series entry: doc_type in {FT, FS, FR, NC, ND}, non-empty prefix, non-empty atcud_code matching the ^[A-Za-z0-9]{8,}$ regex (atcud.rs:4).
  • software_certificate_number is required (struct field, non-optional).

NIF validation

validate_nif (nif.rs:6-23) implements the mod-11 check digit algorithm. First digit must be one of 1, 2, 3, 5, 6, 7, 8, 9 (0 and 4 are reserved). The check digit is:

remainder = sum(digit[i] * (9-i) for i in 0..8) mod 11
check = 0 if remainder <= 1 else 11 - remainder

Used both for the issuer NIF (location tax_id) and any buyer NIF passed in transaction metadata.

AT error catalogue

errors.rs ships a static map of AT codes (AT-001 through AT-902) with English translations (errors.rs:16-113).

Categories (errors.rs:126-142):

4th digitCategory
0auth
1nif
2saft_structure
3atcud
4invoice
5tax
6signature
7submission
9server

Selected codes:

CodeClassDescription
AT-001authinvalid or expired authentication credentials
AT-200saft_structureSAF-T XML does not conform to schema
AT-206saft_structureTaxRegistrationNumber in Header does not match authenticated NIF
AT-300atcudATCUD code is missing or malformed
AT-301atcudATCUD validation series not registered with AT
AT-302atcudATCUD sequential number is not sequential (gap detected)
AT-303atcudATCUD sequential number already used
AT-407invoiceduplicate invoice number in same series
AT-601signaturedigital signature verification failed
AT-602signaturehash of previous document does not match chain
AT-701submissionduplicate submission detected
AT-703submissionrate limit exceeded, retry after cooldown period
AT-900serverinternal server error at AT, please retry

Retriability (errors.rs:144-147):

pub fn is_retriable(code: &str) -> bool {
matches!(code, "AT-703" | "AT-704" | "AT-900" | "AT-901" | "AT-902")
}

So only rate-limit, "previous still processing", and server-class errors retry. ATCUD/auth/schema errors are operator-fix-required.


9. Implementation notes

Crate layout

rust/adapters/portugal/
├── Cargo.toml # crate "portugal"
└── src/
├── lib.rs # public re-exports
├── adapter.rs # Adapter, SequenceStore, InMemorySequenceStore, CountryAdapter impl
├── config.rs # Config, SeriesConfig, resolve_series_type, series_key
├── atcud.rs # format_atcud, validate_atcud_code, AtcudPair
├── qr.rs # generate_qr_content (Portaria 195/2020 Annex)
├── nif.rs # validate_nif (mod-11), generate_test_nif
├── saft.rs # build_saft_xml (SAF-T PT v1.04_01)
├── errors.rs # AT error code translations, error_category, is_retriable
└── types.rs # SaftInput DTOs, QrParams, QrLineItem

Trait fit

CountryAdapter::prepare():

  1. Resolve doc type from (tx_type, amount, buyer_nif) via resolve_series_type.
  2. Look up series config (cfg.series_for(doc_type)).
  3. next_sequence(series_key) for gap-free numbering.
  4. Format ATCUD: <validation_code>-<seq>.
  5. Build doc ID: <series.prefix><seq> (e.g. FT 2026/35).
  6. Compute document hash (SHA-256 of NIF;DocID;ATCUD;Amount, first 4 hex chars).
  7. Map status: A if tx_type == "void", else N.
  8. Build QR content via generate_qr_content (delimited string).
  9. Emit PreparedRecord with qr_code set, submission_payload empty, render hints for ATCUD (header) + QR (footer-right).

CountryAdapter::submit() is a no-op — there is no per-receipt submission.

CountryAdapter::required_certificate_id returns config.at_certificate_id (for the SAF-T mTLS cert) when present.

Idiosyncrasies and gotchas

  1. created_at is rendered as YYYYMMDD in PreparedRecord (adapter.rs:285). This matches AT's QR F field expectation. Don't pass through the original RFC 3339 — AT rejects it.
  2. Final-consumer NIF is hard-coded as 999999990 (adapter.rs:8, qr.rs:8). This is the only NIF AT recognizes as "final consumer (no NIF given)" — don't substitute another value like 999999999.
  3. The "type" field on hash input is tx.amount, not the gross total in EUR or the line-item sum (adapter.rs:186). This matches the legacy Go implementation's hash-extract behavior; verify when diffing against the Go reference.
  4. Hash is currently SHA-256, not RSA-signed (adapter.rs:185-189). AT will reject this for production certification — see §3. The chain-signing migration is tracked separately.
  5. series_for is fallible — if the config lacks a series for the resolved doc type, prepare() errors out (adapter.rs:171). The validator at validate_config does not require all 5 doc types be configured — a merchant who never issues credit notes legitimately omits NC. Make sure upstream knows: configuring only FT means refunds will fail at prepare().
  6. The AT QR is a delimited string, not a URL (adapter.rs:328-331). Some merchants try to "make it a URL" by prefixing https:// — that's wrong, AT rejects it on inspection.
  7. Date format on the wire vs receipt: SAF-T expects YYYY-MM-DD on InvoiceDate but YYYYMMDD on the QR's F field. Both come from the same Transaction.created_at — different formatters.
  8. Regional variants (Açores PT-AC, Madeira PT-MA) are baked into the SAF-T tax-table emitter but not yet exposed at the QR level. If a merchant operates a Madeira location, the tax_country_region must propagate; today's adapter assumes PT (mainland). Tracked as a known limitation.

What's NOT in the adapter

  • SAF-T submission HTTP client: not wired. The adapter only emits XML; sending it to AT is a downstream cron / portal flow.
  • RSA chain signing: not implemented (see §3, §4.2). Required before production AT cert.
  • AT mock server: legacy Go path (internal/adapters/portugal/ mockat.go). Not ported; tests use file goldens instead.
  • NIF lookup against AT registry: the adapter validates format only (nif.rs:6-23). AT's authoritative NIF lookup is a separate webservice not used here.

10. References

Authority documentation

Adapter source

  • rust/adapters/portugal/Cargo.toml — crate manifest.
  • rust/adapters/portugal/src/adapter.rsAdapter, SequenceStore, CountryAdapter impl.
  • rust/adapters/portugal/src/config.rsConfig, SeriesConfig, resolve_series_type, SIMPLIFIED_INVOICE_THRESHOLD, FINAL_CONSUMER_NIF.
  • rust/adapters/portugal/src/atcud.rsformat_atcud, validate_atcud_code.
  • rust/adapters/portugal/src/qr.rsgenerate_qr_content (per Portaria 195/2020 Annex).
  • rust/adapters/portugal/src/nif.rsvalidate_nif (mod-11), generate_test_nif.
  • rust/adapters/portugal/src/saft.rsbuild_saft_xml, SAFT_NAMESPACE, status / type constants, tax_description.
  • rust/adapters/portugal/src/errors.rs — AT error codes, error_category, is_retriable.

Internal docs

  • apps/docs-internal/docs/architecture/portugal-adapter.md — narrative architecture, mock server, certificate management.
  • apps/docs-internal/docs/guides/portugal.md — integration guide (legacy; should migrate to external).
  • apps/docs-internal/docs/countries/portugal-sandbox.md — sandbox onboarding steps.
  • apps/docs/docs/guides/portugal.md — current customer-facing presentation.