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:
- Certified under the NF525 standard (or the LNE-MM65-A standard, the attestation individuelle de l'éditeur path), or
- 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:
- The signature chain integrity (every receipt signs the previous).
- The JET (Journal d'Événements Tracés) audit log integrity.
- The grand-totals counters (Grands Totaux Périodiques + Perpétuel).
- 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_refatconfig.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_idand 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:
SALEorRETURN. - 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:
| Pos | Field | Format | Example |
|---|---|---|---|
| 1 | Total TTC per VAT rate (pipe-separated) | <rate>:<amount> per rate, joined by ` | ` |
| 2 | Total TTC | 2-decimal string | 121.00 |
| 3 | Timestamp | YYYYMMDDHHMMSS UTC | 20260315143022 |
| 4 | Register ID | free-form, ≤ ~50 chars | CAISSE-01 |
| 5 | Sequence number | zero-padded to 6 digits | 000042 |
| 6 | Tx type | SALE or RETURN | SALE |
| 7 | First-record flag | Y if seq=1, else N | N |
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:
- Local conformance fixtures at
testdata/conformance/france/nf525/(signature-chain golden vectors, JET entries, grand-totals snapshots). - Unit tests in
rust/adapters/france/src/signing.rs:238-365andtests.rs— these exercise the full pipeline without any network. - 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()
| Variant | Source | Recovery |
|---|---|---|
FranceError::Config(msg) | invalid SIREN/SIRET/TVA/NAF (Luhn or format) | merchant fixes config, re-register location |
FranceError::NoSigner | adapter constructed without a Signer | wiring bug — ensure signing key is loaded from cert store at startup |
FranceError::Signing(SignError) | ECDSA sign failure (key invalid) or chain lock poisoned | startup failure — replace key from backup or rebuild chain from JET archive |
FranceError::Serialization(serde_json::Error) | receipt JSON serialization | should 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:
sirenempty or fails Luhn (with the La Poste exception for SIREN356000000, which usesdigit-sum % 5 == 0instead of Luhn — seeconfig.rs:93-100).tva_intracommunautaireempty or fails the FR-prefix + 2-char key + 9-digit-SIREN format. The 2-char key may be alphanumeric (excludingIandO).naf_codeempty or not 4 digits + 1 uppercase letter.legal_nameempty.register_idempty.
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_chainfailure) → 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:
- Validate config (already done at registration via
validate_config). - Map
Transaction.tx_type→SALE/RETURN(lib.rs:524-529). - Format Total TTC and per-rate breakdown.
- Sign + chain via
Signer::sign_and_chain(advances chain, returnsseq_number). - Append
JETEntry { event_type: TICKET, ... }to the in-memoryjet_entriesvec. - Update
GrandTotalsfor the register (perpetual, perpetual-abs, daily, monthly, yearly — with year/month/day-boundary auto-resets atlib.rs:339-392). - Compute
signature_extract= chars at 1-indexed positions[3, 7, 13, 19]of the base64url-DER signature (receipt.rs:5-18). - Return
PreparedRecordwithsubmission_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
ExtractSignatureis 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.- 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, theis_firstflag will beNeven on the first new entry. This is correct semantically (the chain is not new) but worth knowing. - 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. - Grand totals use
i64minor units throughout. Refunds decrementperpetual_totalbut incrementperpetual_total_abs— both are needed for the audit (sales − returns, sales + returns). - La Poste SIRET exception (
config.rs:93-100): if SIREN starts with356000000, validate viadigit-sum % 5 == 0rather than Luhn. La Poste was grandfathered in 1997 with non-Luhn-valid SIRET numbers. Yes, really. Don't remove this branch. signing_key_idis optional in the config (config.rs:23). If empty, the SDK auto-generates a P-256 key on first init (seeapps/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 pinsigning_key_idto 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, andjet_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_versionandcertificate_refin config and emit aJETEventType::ConfigChange(lib.rs:282-311).
10. References
Authority documentation
- CGI Article 286-I-3°bis — codified text of the 2016 obligation: https://www.legifrance.gouv.fr/codes/article_lc/LEGIARTI000031817330
- BOI-TVA-DECLA-30-10-30 — DGFiP administrative doctrine on the certified-software requirement: https://bofip.impots.gouv.fr/bofip/10691-PGP.html
- NF525 standard — held by AFNOR / Infocert; the spec itself is
paywalled, but conformance criteria are summarized in
BOI-TVA-DECLA-30-10-30§IV.
Certifying bodies
- LNE (Laboratoire National de Métrologie et d'Essais) — https://www.lne.fr/fr/certification/certification-systemes-encaissement-nf525.
- AFNOR Certification — https://certification.afnor.org/.
- Infocert — https://www.infocert.fr/.
Adapter source
rust/adapters/france/Cargo.toml— crate manifest, dep pins.rust/adapters/france/src/lib.rs—Adapter,CountryAdapterimpl.rust/adapters/france/src/signing.rs— ECDSA P-256, chain logic,verify_chain,cert_thumbprint.rust/adapters/france/src/receipt.rs—ExtractSignature(the 4-char NF525 extract).rust/adapters/france/src/config.rs—FranceLocationConfig, SIREN / SIRET / TVA / NAF validators.rust/adapters/france/src/types.rs—JETEntry,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.