Skip to main content

France — NF525

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

Status: Implemented (legacy Phase-0 wedge). Adapter at rust/adapters/france/ (crate france-nf525). Strategic context: France was one of the four legacy launch countries (FR/IT/PT/ES) — the original Zyntem product surface — alongside Italy, Portugal, Spain. Unlike the Phase-1 cluster (HU/SI/HR/RO/PL/GR), France has no authority API: NF525 enforces compliance by mandating certified software with local cryptographic controls. That makes it our cheapest country to operate per transaction (no upstream calls, no rate limits) but the most expensive to certify (LNE / AFNOR / Infocert audit, ~€10-30K and 6-12 months for a fresh certification). See apps/docs-internal/docs/ops/country-prioritization.md.


1. Regulatory scope

What must be reported

There is no real-time reporting obligation in France. The 2016 Loi de finances (Article 88, loi n° 2015-1785 of 29 December 2015, codified as CGI Article 286-I-3°bis) requires every cash-register or POS system used by a VAT-registered taxpayer to be either:

  1. Certified under the NF525 standard (or the LNE-MM65-A standard, the attestation individuelle de l'éditeur path), or
  2. Backed by a publisher's individual certification (a self-issued attestation declaring conformity to the quatre conditions of inalterability, security, conservation, and archiving).

NF525 (Norme Française 525, "Logiciels de caisse — Exigences de conformité fiscale") is the dominant route for vendors selling to multiple merchants because it covers the four conditions in a single audit.

Who must comply

  • All taxable persons (assujettis à la TVA) in France who use software or a cash register to record receipts owed by a final consumer (B2C).
  • B2B-only invoicing software is technically exempt because the obligation is keyed off "encaissement" (collection of payment), but in practice any POS that handles cash is in scope.
  • Auto-entrepreneurs in the franchise-en-base-de-TVA regime (under the TVA threshold) are explicitly exempt — the software does not have to be certified for them. Our adapter does not gate on this; the merchant's exemption is handled at onboarding.

Exemptions

  • B2B-only invoicing without cash receipt handling (the "Billing & invoicing" path is regulated separately under the upcoming Facture Électronique mandate landing in 2026-2027 — out of scope for this adapter).
  • Auto-entrepreneurs under the franchise (see above).
  • Public-sector / association specific carve-outs in CGI 286-I-3°bis.

Mandatory dates

  • 2018-01-01: Initial NF525 obligation in force (deferred from 2018 by the 2017 amendment).
  • No subsequent expansions — the regime has been stable since 2018.
  • The upcoming B2B facture électronique mandate (2026-09 large enterprises, 2027-09 SMEs) is a separate regime built on the public Portail Public de Facturation (PPF) and is not part of NF525. We track it separately and may add a France e-invoicing adapter later.

2. API surface

There is no API surface. NF525 is enforced entirely client-side:

  • No authority endpoint to call.
  • No taxpayer registration with DGFiP required for the software itself (only the merchant's SIRET/TVA registration, which is independent of POS choice).
  • No signed receipt-tracking number returned by the State.

What the regulator audits, on inspection, is:

  1. The signature chain integrity (every receipt signs the previous).
  2. The JET (Journal d'Événements Tracés) audit log integrity.
  3. The grand-totals counters (Grands Totaux Périodiques + Perpétuel).
  4. The certification reference printed on every receipt (NF525 cert ID + software version).

The "API" the adapter implements is therefore a local pipeline: prepare() runs the signing chain, builds the JET entry, increments the totals, formats the receipt. There is no submit() step.

This is captured in the adapter's CountryAdapter impl (rust/adapters/france/src/lib.rs:404-509):

fn submission_window(&self) -> SubmissionWindow {
SubmissionWindow::None
}

fn supported_deployment_modes(&self) -> &[DeploymentMode] {
&[DeploymentMode::EmbeddedLocal]
}

SubmissionWindow::None is the explicit signal to the orchestrator that the prepared record is the terminal state — no retry worker, no polling.

EmbeddedLocal is the only supported deployment mode. This is the single most important constraint in the adapter. The signing chain and JET log are anchored to a per-register key and counter; running them from a multi-tenant Cloud worker would either:

  • Break the chain model (signatures would chain across merchants), or
  • Require the Cloud worker to round-trip to a per-register secret store and persist chain state per register, which breaks horizontal scaling.

We have not found a regulator-acceptable way to do NF525 from a Cloud deployment. Embedded SDK only. See feedback_use_polecats.md and the country-prioritization.md "Cloud-OK" matrix.


3. Authentication

No authority authentication

The adapter does not authenticate to any external service.

Signing-key provisioning (the actual cred surface)

What is provisioned per-register is a signing key plus a certificate reference. The signing key is an ECDSA P-256 key (cited in Cargo.toml: p256 = { version = "0.13", features = ["ecdsa"] }).

Per rust/adapters/france/src/signing.rs:65-78:

pub fn from_bytes(key_bytes: &[u8], cert_thumbprint: String) -> Result<Self, SignError> {
let signing_key = SigningKey::from_bytes(key_bytes.into())
.map_err(|e| SignError::EcdsaSign(format!("invalid key bytes: {}", e)))?;
Ok(Self::new(signing_key, cert_thumbprint))
}

The cert thumbprint is just a SHA-256 of the DER cert, base64url-encoded (signing.rs:233-236), stamped into every JET entry and signature record so a downstream auditor can verify the chain against the right public key.

Cert lifecycle

NF525 expects the merchant's POS to be certified at install time by the software vendor (us) and to carry the certificate reference on every printed receipt. The cert is issued by an NF525-accredited body (LNE, AFNOR, Infocert) to the software, not to the merchant.

Concretely:

  • We hold a single NF525 cert per software version (e.g. B 525/0498-5).
  • That cert ID is a config field (FranceLocationConfig.certificate_ref at config.rs:18) and gets printed on every receipt.
  • The signing key is per register, not per cert. We mint or import one ECDSA P-256 key per register_id and keep it for the life of that register. Rotation is allowed but breaks the chain and requires an archive event on the JET.

4. Payload shape

Receipt (no XML, no submission)

There is no regulator-mandated wire format. The adapter produces a JSON receipt blob (NF525Receipt at types.rs:75-88):

pub struct NF525Receipt {
pub fiscal_id: String,
pub register_id: String,
pub sequence_number: i64,
pub signature_extract: String, // 4-char extract
pub certificate_ref: String,
pub software_version: String,
pub siret: String,
pub tva: String,
pub legal_name: String,
pub total_ttc: String,
pub timestamp: String,
}

What matters for compliance is the printed-receipt rendering. NF525 mandates the following fields appear on the paper or PDF receipt:

  • Store name, address, SIRET (14-digit establishment ID), TVA intracommunautaire (FR + 2-char key + 9-digit SIREN).
  • NAF code (4 digits + 1 uppercase letter, e.g. 6201Z).
  • Date and time formatted DD/MM/YYYY HH:MM:SS.
  • Register ID ("Caisse").
  • Sequential number, zero-padded to 6 digits.
  • Transaction type: SALE or RETURN.
  • Itemized lines (qty, unit price, VAT rate, totals).
  • Tax breakdown per rate (Taux, Base HT, TVA, TTC).
  • The 4-character signature extract.
  • The NF525 certificate reference + software version.
  • Footer: "Ticket de caisse certifié NF525".

The rendering itself is handled by the receipt-print layer downstream. The adapter exposes the inputs via PreparedRecord.render_hints (see lib.rs:466-499), which the Embedded SDK consumes:

hint_items.push(RenderHintItem {
key: "signature_chain_id".to_string(),
label: Some("Cert.:".to_string()),
value: chain_id,
placement: Placement::FooterBottom,
mandatory: true,
kind: RenderKind::Text,
meta: serde_json::json!({}),
});

Signing payload (the canonical compliance artefact)

The actual compliance artefact is the signature computed over a canonical 7-field payload (signing.rs:142-164):

pub fn BuildSigningPayload(
total_per_rate: &str,
total_ttc: &str,
timestamp: &DateTime<Utc>,
register_id: &str,
seq: i64,
tx_type: &str,
) -> String {
let ts = timestamp.format("%Y%m%d%H%M%S").to_string();
let seq_str = format!("{:06}", seq);
let is_first = if seq == 1 { "Y" } else { "N" };
[
total_per_rate, total_ttc, &ts, register_id, &seq_str, tx_type, is_first,
]
.join(",")
}

Field-by-field:

PosFieldFormatExample
1Total TTC per VAT rate (pipe-separated)<rate>:<amount> per rate, joined by ``
2Total TTC2-decimal string121.00
3TimestampYYYYMMDDHHMMSS UTC20260315143022
4Register IDfree-form, ≤ ~50 charsCAISSE-01
5Sequence numberzero-padded to 6 digits000042
6Tx typeSALE or RETURNSALE
7First-record flagY if seq=1, else NN

The Total TTC per rate field (#1) groups line items by tax_rate (basis points) and emits one rate:total pair per rate, joined by |. See lib.rs:531-547.


5. Ordering and sequencing — the chain state

Per-register chain

Each register_id owns a separate signature chain. New registers start at seq=1 and chain forward forever.

The chain primitive lives at signing.rs:86-138:

let (sig_input, seq_number, is_first) = match state {
None => {
let input = format!("{},", payload); // genesis: trailing comma, no prev sig
(input, 1i64, true)
}
Some(s) => {
let input = format!("{},{}", payload, s.last_signature);
(input, s.last_seq_number + 1, false)
}
};
let hash = Sha256::digest(sig_input.as_bytes());
let signature: P256Signature = self.signing_key.sign_prehash(&hash)?;
let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_der());

So the recurrence is:

sig(N) = ECDSA-P256-DER( SHA-256( payload(N) + "," + sig(N-1) ) )
sig(1) = ECDSA-P256-DER( SHA-256( payload(1) + "," ) ) // empty prev sig

Both the input string and the output signature are stored as DER ECDSA-P-256 then base64url-encoded (no padding) on disk.

Verification (signing.rs:183-230) walks the chain from genesis, re-computes the hash with the previous signature as suffix, and verifies each ECDSA signature with the public key. Any mismatch breaks the chain and is reported as a tampering event.

Independent register chains

Cited from signing.rs:278-288:

#[test]
fn test_independent_register_chains() {
let signer = test_signer();
let r1 = signer.sign_and_chain("REG-A", "payload").unwrap();
let r2 = signer.sign_and_chain("REG-B", "payload").unwrap();

assert_eq!(r1.seq_number, 1);
assert_eq!(r2.seq_number, 1);
// ...
}

Two registers writing under the same signer get independent (register, ChainType) keys. The chains HashMap in Signer is keyed by (String, ChainType) (signing.rs:60), where ChainType discriminates Transaction, ShiftClose, Duplicate, Jet — the latter three are the JET event chain.

Idempotency / retries

There is no "retry the same submission" semantic because there is no authority. Re-issuing a fiscal_id requires a Duplicate JET entry, not a re-signed transaction (lib.rs:216-247). The JETEventType::Duplicate log is itself signed and chained. Concretely:

  • A re-print is a new JET entry, event_type=DUPLICATE, that signs "DUPLICATE,<register>,<original_seq>,<timestamp>".
  • The transaction chain is unaffected — the original tx still owns seq=42, no second tx is created.

Concurrency

The chain map is wrapped in std::sync::Mutex (signing.rs:60-94), so the Embedded SDK serializes per-process. Since NF525 is Embedded-only and each register is typically a single process/device, contention is not an issue. The Postgres-side serialisation referenced in the integration guide (apps/docs-internal/docs/guides/france.md:252-258) is the legacy Go implementation; the current Rust path uses the in-process mutex.


6. Submission window / mode

SubmissionWindow::None. There is no submission, no async finalization, no retry worker. Every transaction is terminal at prepare() exit.

This is unique among our adapters: every other country has either an authority round-trip (HU/SI/HR/PL/GR/RO, IT-DC, IT-SDI, ES-Verifactu, ES-TBAI, ES-SII, PT-SAFT) or a deferred reporting flow (PT monthly SAF-T). France has neither.

The "submission" model is regulatory inspection — DGFiP's brigade de contrôle may walk into the merchant and demand an audit export of the JET, the Grand Totals, and the signature chain at any time. Our obligation is to produce that export on demand. The adapter exposes the inputs (JET entries, grand totals, chain entries) but the export formatting is a separate downstream concern.


7. Sandbox access

There is no sandbox to onboard to. There is no test environment because there is no authority service.

What stands in for a sandbox is:

  1. Local conformance fixtures at testdata/conformance/france/nf525/ (signature-chain golden vectors, JET entries, grand-totals snapshots).
  2. Unit tests in rust/adapters/france/src/signing.rs:238-365 and tests.rs — these exercise the full pipeline without any network.
  3. Cert vendor staging — when we run an NF525 audit (LNE, AFNOR, Infocert), they provide test SIRETs and expected receipt outputs; that's a one-shot exercise per cert renewal, not a daily-test environment.

For new-engineer onboarding, see apps/docs-internal/docs/countries/france-nf525-sandbox.md for the "local fixture" workflow. There is no *-sandbox.md equivalent of the remote-onboarding flow we have for HU/SI/HR/etc.


8. Error model

What can fail at prepare()

VariantSourceRecovery
FranceError::Config(msg)invalid SIREN/SIRET/TVA/NAF (Luhn or format)merchant fixes config, re-register location
FranceError::NoSigneradapter constructed without a Signerwiring bug — ensure signing key is loaded from cert store at startup
FranceError::Signing(SignError)ECDSA sign failure (key invalid) or chain lock poisonedstartup failure — replace key from backup or rebuild chain from JET archive
FranceError::Serialization(serde_json::Error)receipt JSON serializationshould not happen in practice — no recoverable user action

These map into AdapterError::Config (config errors) or AdapterError::Internal (everything else) at lib.rs:38-45. There is no Transient variant because there is no network involved.

Validation in validate_config

Per config.rs:28-62, location config is rejected at registration time if any of:

  • siren empty or fails Luhn (with the La Poste exception for SIREN 356000000, which uses digit-sum % 5 == 0 instead of Luhn — see config.rs:93-100).
  • tva_intracommunautaire empty or fails the FR-prefix + 2-char key + 9-digit-SIREN format. The 2-char key may be alphanumeric (excluding I and O).
  • naf_code empty or not 4 digits + 1 uppercase letter.
  • legal_name empty.
  • register_id empty.

The validate_with_tax_id extra check (config.rs:64-79) cross-validates that the location's tax_id (SIRET) starts with the same 9 digits as the configured SIREN. This catches the most common onboarding bug ("wrong SIRET pasted").

What the regulator considers a failure

If the auditor finds:

  • A gap in the sequence chain for any register → CGI 1729 H+ fine (up to €7,500 + 5% of unreported revenue).
  • A broken signature chain (any verify_chain failure) → same.
  • Missing JET entries (no LOGIN / LOGOUT / ARCHIVE / etc. events bracketing a chunk of activity) → "défaut de tenue de comptabilité informatisée".
  • Wrong NF525 cert reference on the receipt → "non-conformité", refer to the éditeur.

The first three are guarded by the adapter's chain logic + JET log; the fourth by FranceLocationConfig.certificate_ref being mandatory in the config schema and printed via the render hints.


9. Implementation notes

Crate layout

rust/adapters/france/
├── Cargo.toml # crate "france-nf525"
└── src/
├── lib.rs # Adapter, FranceTransaction → AdapterResult, CountryAdapter impl
├── config.rs # FranceLocationConfig + Luhn validators (SIREN/SIRET/TVA/NAF)
├── signing.rs # Signer (ECDSA P-256), ChainState, BuildSigningPayload, verify_chain
├── receipt.rs # ExtractSignature (4-char extract: positions 3, 7, 13, 19)
├── types.rs # JETEventType, JETEntry, GrandTotals, NF525Receipt, TransactionType
└── tests.rs # integration tests for the full prepare() pipeline

Trait fit

CountryAdapter::prepare() is the entire pipeline:

  1. Validate config (already done at registration via validate_config).
  2. Map Transaction.tx_typeSALE / RETURN (lib.rs:524-529).
  3. Format Total TTC and per-rate breakdown.
  4. Sign + chain via Signer::sign_and_chain (advances chain, returns seq_number).
  5. Append JETEntry { event_type: TICKET, ... } to the in-memory jet_entries vec.
  6. Update GrandTotals for the register (perpetual, perpetual-abs, daily, monthly, yearly — with year/month/day-boundary auto-resets at lib.rs:339-392).
  7. Compute signature_extract = chars at 1-indexed positions [3, 7, 13, 19] of the base64url-DER signature (receipt.rs:5-18).
  8. Return PreparedRecord with submission_payload: Vec::new() (no submission), receipt JSON, and render hints for the printer.

submit() is a no-op returning an empty SubmitResult.

Idiosyncrasies and gotchas

  1. ExtractSignature is byte-indexed, not char-indexed (receipt.rs:1-18). This is correct for our base64url-encoded ASCII signatures (which are pure ASCII), but a non-ASCII payload would break it. The base64url-DER encoding of an ECDSA P-256 signature is always ASCII, so we're safe in practice. Don't generalise this function.
  2. First-record flag is by seq=1, not by chain emptiness. If a chain is rebuilt from archived state with a non-1 starting seq, the is_first flag will be N even on the first new entry. This is correct semantically (the chain is not new) but worth knowing.
  3. The signing input for genesis is payload + "," — note the trailing comma even with no previous signature. Older NF525 reference implementations sometimes drop the comma; ours follows the canonical RFC-style "join with ," interpretation, which is what LNE accepts in audit. Don't "fix" this.
  4. Grand totals use i64 minor units throughout. Refunds decrement perpetual_total but increment perpetual_total_abs — both are needed for the audit (sales − returns, sales + returns).
  5. La Poste SIRET exception (config.rs:93-100): if SIREN starts with 356000000, validate via digit-sum % 5 == 0 rather than Luhn. La Poste was grandfathered in 1997 with non-Luhn-valid SIRET numbers. Yes, really. Don't remove this branch.
  6. signing_key_id is optional in the config (config.rs:23). If empty, the SDK auto-generates a P-256 key on first init (see apps/docs-internal/docs/guides/france.md:177-184). This is convenient but means a re-init from a fresh DB will mint a new key and break the chain. Production deployments should always pin signing_key_id to a stable cert-store reference.

What's NOT in the adapter

  • Receipt PDF generation: lives in the rendering layer (the SDK or Receipts service), not here. The adapter only emits render_hints.
  • JET archive export: a downstream concern. The adapter exposes Adapter::jet_entries(&self) -> &[JETEntry], jet_entries_by_register, and jet_entries_by_type (lib.rs:163-182). Format-to-CSV / format-to-XML for an audit export is a separate module.
  • Grand-totals persistence: the adapter holds them in a BTreeMap<String, GrandTotals> in memory. Persistence and recovery are downstream (typically SQLite for embedded, Postgres for cloud legacy).
  • Cert renewal flow: out-of-band. When NF525 cert renews (every 3 years), bump software_version and certificate_ref in config and emit a JETEventType::ConfigChange (lib.rs:282-311).

10. References

Authority documentation

Certifying bodies

Adapter source

  • rust/adapters/france/Cargo.toml — crate manifest, dep pins.
  • rust/adapters/france/src/lib.rsAdapter, CountryAdapter impl.
  • rust/adapters/france/src/signing.rs — ECDSA P-256, chain logic, verify_chain, cert_thumbprint.
  • rust/adapters/france/src/receipt.rsExtractSignature (the 4-char NF525 extract).
  • rust/adapters/france/src/config.rsFranceLocationConfig, SIREN / SIRET / TVA / NAF validators.
  • rust/adapters/france/src/types.rsJETEntry, JETEventType, GrandTotals, NF525Receipt.

Internal docs

  • apps/docs-internal/docs/guides/france.md — integration walkthrough (legacy; should migrate to external).
  • apps/docs-internal/docs/countries/france-nf525-sandbox.md — fixture-based "sandbox" workflow.
  • apps/docs/docs/guides/france.md — current customer-facing presentation.