Extract the fields of an invoice
Pull the number, the date and the amount due out of an invoice, whatever the supplier’s layout.
No AI needed
FreeInstantYour data never leaves
The verdict
N0, and the reform is most of the reason. Since 1 September 2026 an invoice between French businesses arrives as UBL, CII or Factur-X through an approved platform: the number, the date and the amounts are in the file, named by the EN 16931 standard, and reading them takes twenty lines. On that half of the mail no model has anything to add, and the standard even hands you the check the other levels lack: total with VAT = total without VAT + VAT, its BR-CO-15 rule, which catches the well-formed wrong reading for nothing. What is left is the remainder — suppliers outside France, till receipts, the backlog, invoices from before the reform — and that is where extraction still earns its keep: label anchors hold for one supplier and fall over on the next, which the snippet shows and which the same function flags by saying which door the answer came through. N1 survives a change of template, but mistakes a legal footer that every business-to-business invoice carries for the amount due, and labelling it does not help. N3 reads what N1 misses, at the price of your invoices in a third party's hands and of an answer whose shape you can check but whose truth you cannot. Go there for the remainder if its volume justifies it, knowing that it shrinks as the reform takes hold.
Read the structured file, then label anchors for what is left
No AI needed
Rule and classic algorithmN0FreeInstant
Python
"""
Read an invoice: the structured file first, the page only if there is none.
Rung N0. No model, no training set, no service.
Since 1 September 2026 every business in France has to be able to receive its
invoices in a structured electronic format, through an approved platform, and
the minimum set of formats is UBL, CII and Factur-X, all three carrying the
European semantic standard EN 16931. That changes the order of operations for
this job: when the invoice arrives structured, its fields are not extracted,
they are read, and the answer is exact. Extraction is for what is left — a
supplier outside France, a till receipt, an invoice from before the reform,
the backlog.
So this file has two doors and one rule: say which door the answer came
through. A number read from XML and a number read from a line of text are not
worth the same, and the caller has to be able to tell them apart.
Getting the XML out of a Factur-X PDF is not this file's job: it is an
attached file inside the PDF, and any PDF library lists it. What arrives here
is the XML itself, or the text of a page.
"""
import re
import xml.etree.ElementTree as ElementTree
# The five fields, named by their business term in EN 16931, then by the local
# name of the element and of its parent in each of the two syntaxes. One pair
# is enough to find them without carrying a page of namespace declarations.
SUMMATION = "SpecifiedTradeSettlementHeaderMonetarySummation"
STRUCTURED = {
# BT-1, BT-2: /rsm:ExchangedDocument/ram:ID and /ram:IssueDateTime/udt:DateTimeString
"invoice_number": (("ExchangedDocument", "ID"), ("Invoice", "ID")),
"date": (("IssueDateTime", "DateTimeString"), ("Invoice", "IssueDate")),
# BT-109, BT-110, BT-112
"total_excluding_vat": ((SUMMATION, "TaxBasisTotalAmount"), ("LegalMonetaryTotal", "TaxExclusiveAmount")),
"vat": ((SUMMATION, "TaxTotalAmount"), ("TaxTotal", "TaxAmount")),
"total": ((SUMMATION, "GrandTotalAmount"), ("LegalMonetaryTotal", "TaxInclusiveAmount")),
}
class UnreadableInvoice(Exception):
"""The document is neither of the two syntaxes this reads."""
def read_structured(xml: bytes) -> dict:
"""
Read the fields of a UBL or CII invoice, and check the one sum EN 16931
makes a rule.
That rule, BR-CO-15, is the cheapest guard there is against a reading that
is well-formed and wrong: total with VAT = total without VAT + VAT. It
does not apply to the extended profile, so a false `totals_agree` is a
reason to look, not a reason to reject.
"""
values = {}
root = ElementTree.fromstring(xml)
for parent in root.iter():
for child in parent:
values.setdefault((_local(parent.tag), _local(child.tag)), (child.text or "").strip())
syntax = 0 if _local(root.tag) == "CrossIndustryInvoice" else 1
if syntax == 1 and _local(root.tag) != "Invoice":
raise UnreadableInvoice(f"neither CII nor UBL: {_local(root.tag)}")
fields = {name: values.get(keys[syntax]) for name, keys in STRUCTURED.items()}
if fields["invoice_number"] is None:
raise UnreadableInvoice("no invoice number in the document")
return {
"source": "structured",
"invoice_number": fields["invoice_number"],
"date": _date(fields["date"]),
**{name: _amount(fields[name]) for name in ("total_excluding_vat", "vat", "total")},
"totals_agree": _totals_agree(fields),
}
def _local(tag: str) -> str:
"""The name of an element without its namespace."""
return tag.rpartition("}")[2]
def _date(raw: str | None) -> str | None:
"""CII writes 20260915, UBL writes 2026-09-15; the caller gets one shape."""
if not raw:
return None
digits = re.sub(r"\D", "", raw)
return f"{digits[:4]}-{digits[4:6]}-{digits[6:8]}" if len(digits) >= 8 else None
def _amount(raw: str | None) -> float | None:
"""An amount in the two syntaxes is a decimal point and nothing else."""
try:
return round(float(raw), 2)
except (TypeError, ValueError):
return None
def _totals_agree(fields: dict) -> bool | None:
"""None when the invoice does not carry the three amounts the rule needs."""
amounts = [_amount(fields[name]) for name in ("total_excluding_vat", "vat", "total")]
if any(value is None for value in amounts):
return None
without, vat, total = amounts
return abs(without + vat - total) < 0.005
# ---------------------------------------------------------------------------
# The other door: a page of text, for what the reform does not cover
# ---------------------------------------------------------------------------
# An amount, signed or not, with two decimals: "1 234,56" and "1.234,56" in
# French, "1,234.56" in English. Exactly three digits per group keeps "2 38,50"
# apart, a quantity then a price; "2 380,50" still reads as one number. At most
# four groups, up to the trillions, so that a long run of digit groups is read
# in linear time.
AMOUNT = r"[-\u2212]?(?<![\d.,])\d{1,3}(?:(?:[\s.]\d{3}){0,4},|(?:[\s,]\d{3}){0,4}\.)\d{2}(?![.,]?\d)"
DATE = r"\d{1,2}/\d{1,2}/\d{2,4}"
REFERENCE = r"\b(?:[A-Za-z]{1,3}[-/])?\d[\dA-Za-z/-]{3,}"
def parse_amount(raw: str) -> float:
"""Turn a written amount into a number the caller can compute with."""
# An AMOUNT match always ends on two decimals: whatever the separators, the
# last two digits are the cents.
cents = int(re.sub(r"\D", "", raw)) / 100
return -cents if raw.lstrip()[:1] in ("-", "\u2212") else cents
# Per field: the labels to look for, most specific first, the pattern the value
# must match after the label, and how to read the match.
FIELDS = {
"invoice_number": (("facture n°", "facture no", "n° facture"), REFERENCE, str.strip),
"date": (("date",), DATE, str.strip),
"total": (("total ttc", "montant ttc", "total"), AMOUNT, parse_amount),
}
def find_after_label(text: str, labels: tuple[str, ...], pattern: str) -> str | None:
"""
First value matching `pattern` after one of `labels`, on the same line.
A label that appears on a line holding no value is skipped rather than
accepted, because a column heading is a label too.
"""
# Every run of spaces, a non-breaking one included, reads as one space.
lines = [re.sub(r"\s+", " ", line) for line in text.splitlines()]
for label in labels:
for line in lines:
position = line.lower().find(label)
if position == -1:
continue
match = re.search(pattern, line[position + len(label):])
if match:
return match.group(0)
return None
def extract_fields(text: str) -> dict:
"""
Read the invoice number, the date and the total from extracted text.
Three fields, and no arithmetic to check them against: what the structured
door gets for nothing, this one cannot have.
"""
fields = {"source": "text"}
for name, (labels, pattern, read) in FIELDS.items():
raw = find_after_label(text, labels, pattern)
fields[name] = read(raw) if raw is not None else None
return {**fields, "total_excluding_vat": None, "vat": None, "totals_agree": None}
def read_invoice(document) -> dict:
"""
One invoice, whichever way it arrived, and the door it came through.
`document` is the structured file when the platform delivered one — bytes
of UBL or CII, or the `factur-x.xml` your PDF library handed you — and the
text of the page when it did not. Nothing here decides that for you: the
type of what you pass in does.
"""
if isinstance(document, (bytes, bytearray)):
return read_structured(bytes(document))
return extract_fields(document)JavaScript
/**
* Read an invoice: the structured file first, the page only if there is none.
*
* Rung N0. No model, no training set, no service.
*
* Since 1 September 2026 every business in France has to be able to receive
* its invoices in a structured electronic format, through an approved
* platform, and the minimum set of formats is UBL, CII and Factur-X, all
* three carrying the European semantic standard EN 16931. That changes the
* order of operations for this job: when the invoice arrives structured, its
* fields are not extracted, they are read, and the answer is exact.
* Extraction is for what is left — a supplier outside France, a till receipt,
* an invoice from before the reform, the backlog.
*
* So this file has two doors and one rule: say which door the answer came
* through. A number read from XML and a number read from a line of text are
* not worth the same, and the caller has to be able to tell them apart.
*
* Getting the XML out of a Factur-X PDF is not this file's job: it is an
* attached file inside the PDF, and any PDF library lists it. What arrives
* here is the XML itself, or the text of a page.
*/
import { XMLParser } from 'fast-xml-parser';
// The five fields, named by their business term in EN 16931, then by the local
// name of the element and of its parent in each of the two syntaxes. One pair
// is enough to find them without carrying a page of namespace declarations.
const SUMMATION = 'SpecifiedTradeSettlementHeaderMonetarySummation';
const STRUCTURED = {
// BT-1, BT-2: /rsm:ExchangedDocument/ram:ID and /ram:IssueDateTime/udt:DateTimeString
invoice_number: [['ExchangedDocument', 'ID'], ['Invoice', 'ID']],
date: [['IssueDateTime', 'DateTimeString'], ['Invoice', 'IssueDate']],
// BT-109, BT-110, BT-112
total_excluding_vat: [[SUMMATION, 'TaxBasisTotalAmount'], ['LegalMonetaryTotal', 'TaxExclusiveAmount']],
vat: [[SUMMATION, 'TaxTotalAmount'], ['TaxTotal', 'TaxAmount']],
total: [[SUMMATION, 'GrandTotalAmount'], ['LegalMonetaryTotal', 'TaxInclusiveAmount']],
};
export class UnreadableInvoice extends Error {}
// Namespaces dropped, attributes ignored, values left as written: an amount is
// read here and not by the parser.
const parser = new XMLParser({
ignoreAttributes: true,
removeNSPrefix: true,
parseTagValue: false,
ignoreDeclaration: true,
});
/**
* Read the fields of a UBL or CII invoice, and check the one sum EN 16931
* makes a rule.
*
* That rule, BR-CO-15, is the cheapest guard there is against a reading that
* is well-formed and wrong: total with VAT = total without VAT + VAT. It does
* not apply to the extended profile, so a false `totals_agree` is a reason to
* look, not a reason to reject.
*/
export function readStructured(xml) {
const document = parser.parse(String(xml));
const [rootName] = Object.keys(document);
const syntax = rootName === 'CrossIndustryInvoice' ? 0 : 1;
if (syntax === 1 && rootName !== 'Invoice') {
throw new UnreadableInvoice(`neither CII nor UBL: ${rootName}`);
}
const values = new Map();
collect(document, '', values);
const fields = Object.fromEntries(
Object.entries(STRUCTURED).map(([name, keys]) => [name, values.get(keys[syntax].join('/'))]),
);
if (fields.invoice_number === undefined) throw new UnreadableInvoice('no invoice number in the document');
return {
source: 'structured',
invoice_number: fields.invoice_number,
date: readDate(fields.date),
total_excluding_vat: readAmount(fields.total_excluding_vat),
vat: readAmount(fields.vat),
total: readAmount(fields.total),
totals_agree: totalsAgree(fields),
};
}
/** Every (parent, child) pair of the tree, first one wins, as in Python. */
function collect(node, name, values) {
for (const [child, value] of Object.entries(node)) {
const first = Array.isArray(value) ? value[0] : value;
if (first !== null && typeof first === 'object') collect(first, child, values);
else if (!values.has(`${name}/${child}`)) values.set(`${name}/${child}`, String(first ?? '').trim());
}
}
/** CII writes 20260915, UBL writes 2026-09-15; the caller gets one shape. */
function readDate(raw) {
const digits = String(raw ?? '').replace(/\D/g, '');
return digits.length >= 8 ? `${digits.slice(0, 4)}-${digits.slice(4, 6)}-${digits.slice(6, 8)}` : null;
}
/** An amount in the two syntaxes is a decimal point and nothing else. */
function readAmount(raw) {
const value = Number(raw);
return raw === undefined || raw === '' || Number.isNaN(value) ? null : Math.round(value * 100) / 100;
}
/** Null when the invoice does not carry the three amounts the rule needs. */
function totalsAgree(fields) {
const [without, vat, total] = ['total_excluding_vat', 'vat', 'total'].map((n) => readAmount(fields[n]));
if (without === null || vat === null || total === null) return null;
return Math.abs(without + vat - total) < 0.005;
}
// ---------------------------------------------------------------------------
// The other door: a page of text, for what the reform does not cover
// ---------------------------------------------------------------------------
// An amount, signed or not, with two decimals: "1 234,56" and "1.234,56" in French,
// "1,234.56" in English. Exactly three digits per group keeps "2 38,50" apart,
// a quantity then a price; "2 380,50" still reads as one number. At most four
// groups, up to the trillions, so that a long run of digit groups is read in
// linear time.
export const AMOUNT = /[-−]?(?<![\d.,])\d{1,3}(?:(?:[\s.]\d{3}){0,4},|(?:[\s,]\d{3}){0,4}\.)\d{2}(?![.,]?\d)/;
const DATE = /\d{1,2}\/\d{1,2}\/\d{2,4}/;
const REFERENCE = /\b(?:[A-Za-z]{1,3}[-/])?\d[\dA-Za-z/-]{3,}/;
/** Turn a written amount into a number the caller can compute with. */
export function parseAmount(raw) {
// An AMOUNT match always ends on two decimals: whatever the separators, the
// last two digits are the cents.
const cents = Number(raw.replace(/\D/g, '')) / 100;
return ['-', '−'].includes(raw.trimStart()[0]) ? -cents : cents;
}
const trim = (raw) => raw.trim();
// Per field: the labels to look for, most specific first, the pattern the value
// must match after the label, and how to read the match.
const FIELDS = {
invoice_number: [['facture n°', 'facture no', 'n° facture'], REFERENCE, trim],
date: [['date'], DATE, trim],
total: [['total ttc', 'montant ttc', 'total'], AMOUNT, parseAmount],
};
/**
* First value matching `pattern` after one of `labels`, on the same line.
*
* A label that appears on a line holding no value is skipped rather than
* accepted, because a column heading is a label too.
*/
export function findAfterLabel(text, labels, pattern) {
// Every run of spaces, a non-breaking one included, reads as one space.
const lines = text.split(/\r?\n/).map((line) => line.replace(/\s+/g, ' '));
for (const label of labels) {
for (const line of lines) {
const position = line.toLowerCase().indexOf(label);
if (position === -1) continue;
const match = line.slice(position + label.length).match(pattern);
if (match) return match[0];
}
}
return null;
}
/**
* Read the invoice number, the date and the total from extracted text.
*
* Three fields, and no arithmetic to check them against: what the structured
* door gets for nothing, this one cannot have.
*/
export function extractFields(text) {
const fields = { source: 'text' };
for (const [name, [labels, pattern, read]] of Object.entries(FIELDS)) {
const raw = findAfterLabel(text, labels, pattern);
fields[name] = raw === null ? null : read(raw);
}
return { ...fields, total_excluding_vat: null, vat: null, totals_agree: null };
}
/**
* One invoice, whichever way it arrived, and the door it came through.
*
* `document` is the structured file when the platform delivered one — bytes of
* UBL or CII, or the `factur-x.xml` your PDF library handed you — and the text
* of the page when it did not. Nothing here decides that for you: the type of
* what you pass in does.
*/
export function readInvoice(document) {
if (typeof document !== 'string') return readStructured(new TextDecoder().decode(document));
return extractFields(document);
}What this means for you
- Your data stays put
- Always the same result
- Unit-testable, case by case
- No provider in the loop
- Negligible footprint
What this adds to your regulatory scope
- No specific scope added: the invoice never leaves your infrastructure
- An invoice from a sole trader carries personal data, processed here on your own infrastructure alone
Breaking point
The structured door has no breaking point: what the XML says is what is read, and the standard’s BR-CO-15 rule — total with VAT = total without VAT + VAT — checks the whole thing for nothing. It is the other door that breaks, and it breaks on the next supplier. It does not lay its page out like the one the rules were written for. It writes « N° » where the first wrote « Facture n° », spells the date out in words, and calls the amount due « NET A PAYER »: all three fields fall over. Two come back empty, and that shows. The third is the bad one: the total comes back well-formed and wrong, because « Sous-total » contains « total ».
When to move up a level
Your remainder is not shrinking: invoices with no structured file keep arriving from suppliers you had not planned for, and you open the label list back up for each of them.
The four options
- RecommendedN0Read the structured file, then label anchors for what is leftFree · Instant
- N1Position and shape features, then a line classifiernégligeable · Instant
- N2Not worth it here
- N3Structured extraction through a general-purpose multimodal modelélevé · Over a second
The other options
N1Position and shape features, then a line classifiernégligeable · Instant
Position and shape features, then a line classifier
Lightweight classic modelN1négligeableInstant
Python
"""
Label each line of the invoice, then read the value out of the line.
Rung N1. N0 asked "what does this line say". This asks "where does this line
sit, and what does it look like": how far down the page, how indented, how
wordy, how many amounts, and whether the value hangs on the right-hand side.
Those features survive a change of supplier, which is exactly what the labels
of N0 do not. Training is a few dozen labelled lines, the model is a few
kilobytes, and nothing is downloaded.
"""
import re
from sklearn.linear_model import LogisticRegression
AMOUNT = r"\d{1,3}(?:[\s.]\d{3})*[,.]\d{2}"
# Month names in any case, accents typed or not.
MONTHS = "janvier|f[ée]vrier|mars|avril|mai|juin|juillet|ao[ûu]t|septembre|octobre|novembre|d[ée]cembre"
DATE = re.compile(rf"\d{{1,2}}/\d{{1,2}}/\d{{2,4}}|\d{{1,2}}\s+(?:{MONTHS})\s+\d{{4}}", re.I)
REFERENCE = r"\b(?:[A-Za-z]{1,3}[-/])?\d[\dA-Za-z/-]{3,}"
def page_lines(text: str) -> list[str]:
"""The lines that carry something, indentation kept: it is a feature."""
return [line for line in text.splitlines() if line.strip()]
def line_features(line: str, index: int, count: int) -> list[float]:
"""Where the line sits and what it looks like. Never what it says."""
text = line.strip()
amounts = re.findall(AMOUNT, text)
letters = "".join(c for c in text if c.isalpha())
return [
index / max(count - 1, 1), # how far down the page
1.0 if index == count - 1 else 0.0, # the very last line
min(len(line) - len(line.lstrip()), 40) / 40, # indentation
min(len(text.split()), 12) / 12, # how wordy
sum(c.isdigit() for c in text) / len(text), # digit share
min(len(amounts), 3) / 3, # how many amounts
1.0 if re.search(DATE, text) else 0.0,
1.0 if re.search(REFERENCE, text) else 0.0,
# A value hanging on the right of the line, the way a totals block does.
1.0 if amounts and text.rindex(amounts[-1]) > len(text) / 2 else 0.0,
1.0 if letters and letters.isupper() else 0.0,
]
def train(documents: list[str], labels: list[list[str]]):
"""`labels` carries one label per non-blank line of each document."""
rows, targets = [], []
for document, document_labels in zip(documents, labels):
lines = page_lines(document)
rows += [line_features(line, i, len(lines)) for i, line in enumerate(lines)]
targets += list(document_labels)
model = LogisticRegression(class_weight="balanced", max_iter=1000)
model.fit(rows, targets)
return model
def _match(pattern: str):
"""Reads the first value of that shape out of a line, or nothing."""
def read(line):
found = re.search(pattern, line)
return found.group(0) if found else None
return read
def _amount(line):
"""The rightmost amount: an item line carries a quantity and a unit price."""
amounts = re.findall(AMOUNT, line)
if not amounts:
return None
cleaned = re.sub(r"[^\d,.]", "", amounts[-1])
# A comma means French spelling: the dots left are thousands separators.
if "," in cleaned:
cleaned = cleaned.replace(".", "").replace(",", ".")
return float(cleaned)
READERS = {"invoice_number": _match(REFERENCE), "date": _match(DATE), "total": _amount}
def extract_fields(model, text: str) -> dict:
"""
For each field, walk the lines from the most likely down.
The model points at a line; reading a date or an amount out of it is still
ours to do, and a line the model likes but that holds no value is not an
answer.
"""
lines = page_lines(text)
if not lines:
return {name: None for name in READERS}
rows = [line_features(line, i, len(lines)) for i, line in enumerate(lines)]
scores = model.predict_proba(rows)
classes = list(model.classes_)
fields = {}
for name, read in READERS.items():
column = classes.index(name)
ranked = sorted(range(len(lines)), key=lambda i: -scores[i][column])
fields[name] = next((v for v in (read(lines[i]) for i in ranked) if v is not None), None)
return fieldsJavaScript
/**
* Label each line of the invoice, then read the value out of the line.
*
* Rung N1. N0 asked "what does this line say". This asks "where does this
* line sit, and what does it look like": how far down the page, how indented,
* how wordy, how many amounts, and whether the value hangs on the right-hand
* side.
*
* Those features survive a change of supplier, which is exactly what the
* labels of N0 do not. Training is a few dozen labelled lines, and logistic
* regression is short enough to be read rather than imported.
*/
const AMOUNT = /\d{1,3}(?:[\s.]\d{3})*[,.]\d{2}/g;
// Month names in any case, accents typed or not.
const MONTHS = 'janvier|f[ée]vrier|mars|avril|mai|juin|juillet|ao[ûu]t|septembre|octobre|novembre|d[ée]cembre';
const DATE = new RegExp(`\\d{1,2}/\\d{1,2}/\\d{2,4}|\\d{1,2}\\s+(?:${MONTHS})\\s+\\d{4}`, 'i');
const REFERENCE = /\b(?:[A-Za-z]{1,3}[-/])?\d[\dA-Za-z/-]{3,}/;
/** The lines that carry something, indentation kept: it is a feature. */
export function pageLines(text) {
return text.split('\n').filter((line) => line.trim() !== '');
}
/** Where the line sits and what it looks like. Never what it says. */
export function lineFeatures(line, index, count) {
const text = line.trim();
const amounts = text.match(AMOUNT) ?? [];
const letters = text.replace(/[^\p{L}]/gu, '');
const words = text.split(/\s+/).filter(Boolean);
return [
index / Math.max(count - 1, 1), // how far down the page
index === count - 1 ? 1 : 0, // the very last line
Math.min(line.length - line.trimStart().length, 40) / 40, // indentation
Math.min(words.length, 12) / 12, // how wordy
(text.match(/\d/g) ?? []).length / text.length, // digit share
Math.min(amounts.length, 3) / 3, // how many amounts
DATE.test(text) ? 1 : 0,
REFERENCE.test(text) ? 1 : 0,
// A value hanging on the right of the line, the way a totals block does.
amounts.length && text.lastIndexOf(amounts.at(-1)) > text.length / 2 ? 1 : 0,
letters && letters === letters.toUpperCase() ? 1 : 0,
];
}
/**
* One binary classifier per label, each trained on every line.
*
* Each line is weighted so that the lines carrying the label weigh, together,
* as much as the lines that do not: on a page, they are the few.
*/
function trainOne(rows, targets, label, epochs, rate) {
const wanted = targets.map((t) => (t === label ? 1 : 0));
const positives = wanted.reduce((a, b) => a + b, 0);
const weights = new Float64Array(rows[0].length);
let bias = 0;
for (let epoch = 0; epoch < epochs; epoch += 1) {
for (let i = 0; i < rows.length; i += 1) {
const balance = rows.length / (2 * (wanted[i] ? positives : rows.length - positives));
let z = bias;
for (let j = 0; j < weights.length; j += 1) z += weights[j] * rows[i][j];
const error = (1 / (1 + Math.exp(-z)) - wanted[i]) * balance;
for (let j = 0; j < weights.length; j += 1) weights[j] -= rate * error * rows[i][j];
bias -= rate * error;
}
}
return { weights, bias };
}
/** `labels` carries one label per non-blank line of each document. */
export function train(documents, labels, { epochs = 400, rate = 0.3 } = {}) {
const rows = [];
const targets = [];
documents.forEach((document, d) => {
const lines = pageLines(document);
lines.forEach((line, i) => rows.push(lineFeatures(line, i, lines.length)));
targets.push(...labels[d]);
});
const classes = [...new Set(targets)].sort();
return { classes, models: classes.map((c) => trainOne(rows, targets, c, epochs, rate)) };
}
function score(model, row) {
let z = model.bias;
for (let j = 0; j < row.length; j += 1) z += model.weights[j] * row[j];
return 1 / (1 + Math.exp(-z));
}
/** Reads the first value of that shape out of a line, or nothing. */
const matching = (pattern) => (line) => line.match(pattern)?.[0] ?? null;
/** The rightmost amount: an item line carries a quantity and a unit price. */
function amount(line) {
const amounts = line.match(AMOUNT);
if (!amounts) return null;
let cleaned = amounts.at(-1).replace(/[^\d,.]/g, '');
// A comma means French spelling: the dots left are thousands separators.
if (cleaned.includes(',')) cleaned = cleaned.replaceAll('.', '').replace(',', '.');
return Number(cleaned);
}
const READERS = { invoice_number: matching(REFERENCE), date: matching(DATE), total: amount };
/**
* For each field, walk the lines from the most likely down.
*
* The model points at a line; reading a date or an amount out of it is still
* ours to do, and a line the model likes but that holds no value is not an
* answer.
*/
export function extractFields(model, text) {
const lines = pageLines(text);
const rows = lines.map((line, i) => lineFeatures(line, i, lines.length));
const fields = {};
for (const [name, read] of Object.entries(READERS)) {
const column = model.classes.indexOf(name);
const ranked = rows
.map((row, i) => [score(model.models[column], row), i])
.sort((a, b) => b[0] - a[0]);
fields[name] = ranked.map(([, i]) => read(lines[i])).find((value) => value !== null) ?? null;
}
return fields;
}What this means for you
- Your data stays put
- Always the same result
- Measured on a sample, not case by case
- Depends on a library
- Low footprint
What this adds to your regulatory scope
- Processing of invoices on your own infrastructure
- The training set holds real invoices: it belongs in your record of processing activities, where you keep one
Breaking point
The fixed recovery indemnity that every business-to-business invoice carries in its footer. It is an amount, alone on its line, at the bottom and on the right: to the features, it looks more like a total than the total does, and the classifier keeps it. Labelling it does not help: trained on that very invoice, with the indemnity labelled as a line carrying no field, it still keeps it, because its features do not tell it apart from the total.
When to move up a level
A footer amount passes for the amount due, adding it, labelled, to the training set does not change the reading, and the volume of that remainder justifies paying for a call per invoice.
N2Not worth it here
Not worth it here
Small self-hosted specialised modelN2
A document-understanding model — LayoutLM and its cousins — reads the line, its neighbours and their place on the page, which is exactly the signal N1 lacks. But there is nothing to publish here: the reference checkpoint, `microsoft/layoutlmv3-base`, is an unfine-tuned encoder with no invoice labels, and its card releases it under Creative Commons BY-NC-SA, that is, outside commercial use; no checkpoint fine-tuned on invoices under a commercial licence stands out; and nothing reads their equivalent in JavaScript. A snippet that does not run has no place on this ladder. What this level would buy applies mostly to the remainder the reform leaves — and that remainder is shrinking.
N3Structured extraction through a general-purpose multimodal modelélevé · Over a second
Structured extraction through a general-purpose multimodal model
General-purpose LLM APIN3élevéOver a second
Python
"""
Ask a general-purpose multimodal model to read the invoice.
Rung N3. The model is sent a picture of the page along with its text, and the
picture still holds what the extracted text has lost: the column an amount
sits in.
Note what the code has to do that N0 did not: cap the size of what it sends,
retry on failure, parse an answer that is only probably JSON, and check the
shape of what came back. That plumbing is the real cost of this rung, and it is
the part your tests have to cover, because the model itself is not testable.
One check here is not about shape. The three amounts are asked for, not just
the one that is due, so that the sum EN 16931 makes a rule of — total with VAT
= total without VAT + VAT — can be recomputed on what the model wrote. It
catches nothing the model gets consistently wrong, and it catches a digit read
off the wrong line, which is the failure this entry is about.
"""
from __future__ import annotations
import base64
import json
import math
PROMPT = (
"Read the invoice below and return its header fields.\n"
"Answer with JSON only: an object with the keys `invoice_number`, `date`,\n"
"`total_excluding_vat`, `vat` and `total`. `total` is the amount due, taxes\n"
"included, as a number.\n"
"Use null for a field the page does not carry.\n\n"
"Extracted text:\n{text}"
)
# The provider bills every token of the prompt: the text is capped before the
# call, in characters, not tokens. An image is billed by its dimensions, not its
# bytes, so its cap only bounds what is encoded and uploaded.
MAX_CHARACTERS = 8000
MAX_IMAGE_BYTES = 4_000_000
FIELDS = ("invoice_number", "date", "total_excluding_vat", "vat", "total")
AMOUNTS = ("total_excluding_vat", "vat", "total")
# The provider named here is an example, not a recommendation: the reasoning
# holds for any general-purpose model API, and the client is swappable. Pass
# any object with a `complete(prompt=..., image_url=..., temperature=...)` method.
MODEL = "gpt-4.1-mini" # an example id: check the parameters your model accepts
class ExtractionUnavailable(Exception):
"""The provider could not be reached, or answered something unusable."""
class ProviderClient:
"""The one call this snippet makes, on top of the provider's SDK."""
def __init__(self, sdk=None, model: str = MODEL):
if sdk is None: # pragma: no cover - needs a key and a network
from openai import OpenAI
sdk = OpenAI()
self.sdk, self.model = sdk, model
def complete(self, *, prompt: str, image_url: str, temperature: float) -> str:
response = self.sdk.chat.completions.create(
model=self.model,
# The page travels inside the message, as a data URL.
messages=[{"role": "user", "content": [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": image_url}},
]}],
temperature=temperature,
)
return response.choices[0].message.content
def extract_fields(text: str, page_image: bytes, client=None, *, attempts: int = 3) -> dict:
"""
Read the invoice fields from its text and a picture of the page.
`client` is injected so this function can be tested without a network call.
In production it defaults to a real provider client.
"""
if len(text) > MAX_CHARACTERS:
raise ValueError(f"text longer than {MAX_CHARACTERS} characters")
if not page_image or len(page_image) > MAX_IMAGE_BYTES:
raise ValueError(f"page image empty or larger than {MAX_IMAGE_BYTES} bytes")
client = client or ProviderClient()
image_url = "data:image/png;base64," + base64.b64encode(page_image).decode()
return _decode(_ask(client, text, image_url, attempts))
def _ask(client, text: str, image_url: str, attempts: int) -> str:
last_error: Exception | None = None
for _ in range(attempts):
try:
answer = client.complete(
prompt=PROMPT.format(text=text),
image_url=image_url,
# Temperature zero, because an amount that changes between two
# identical calls cannot be reconciled with anything.
temperature=0,
)
if isinstance(answer, str):
return answer
last_error = ValueError("the model returned no content") # a refusal: unusable, not empty
except Exception as error: # noqa: BLE001 - any provider failure is retried
last_error = error
raise ExtractionUnavailable(str(last_error))
def _decode(answer: str) -> dict:
"""
Turn the answer into the five fields, or refuse it.
Models like to wrap JSON in a code fence. That is noise, not an error, and
stripping it is cheaper than another call.
"""
stripped = answer.strip()
if stripped.startswith("```") and stripped.endswith("```") and stripped.count("```") == 2:
stripped = stripped[3:-3].removeprefix("json")
try:
parsed = json.loads(stripped)
except ValueError as error:
raise ExtractionUnavailable(f"the model did not answer with JSON: {error}")
if not isinstance(parsed, dict):
raise ExtractionUnavailable("the model answered something that is not an object")
fields = {field: parsed.get(field) for field in FIELDS}
for field in ("invoice_number", "date"):
if fields[field] is not None and not isinstance(fields[field], str):
raise ExtractionUnavailable(f"the model answered a {field} that is not text: {fields[field]!r}")
for field in AMOUNTS:
value = fields[field]
# An amount nobody can compute with is worse than none at all: it would
# travel down the pipeline looking like a number.
if value is not None and (isinstance(value, bool) or not isinstance(value, (int, float))
or not math.isfinite(value)):
raise ExtractionUnavailable(f"the model answered a {field} that is not a number: {value!r}")
return {**fields, "totals_agree": _totals_agree(fields)}
def _totals_agree(fields: dict) -> bool | None:
"""
BR-CO-15 recomputed on what the model wrote, or None if it wrote too little.
False is not a reason to throw the answer away: it is the one reason the
caller has to look at this invoice rather than the other four hundred.
"""
amounts = [fields[field] for field in AMOUNTS]
if any(value is None for value in amounts):
return None
without, vat, total = amounts
return abs(without + vat - total) < 0.005JavaScript
/**
* Ask a general-purpose multimodal model to read the invoice.
*
* Rung N3. The model is sent a picture of the page along with its text, and
* the picture still holds what the extracted text has lost: the column an
* amount sits in.
*
* Note what the code has to do that N0 did not: cap the size of what it sends,
* retry on failure, parse an answer that is only probably JSON, and check the
* shape of what came back. That plumbing is the real cost of this rung, and it
* is the part your tests have to cover, because the model itself is not
* testable.
*
* One check here is not about shape. The three amounts are asked for, not just
* the one that is due, so that the sum EN 16931 makes a rule of — total with
* VAT = total without VAT + VAT — can be recomputed on what the model wrote.
* It catches nothing the model gets consistently wrong, and it catches a digit
* read off the wrong line, which is the failure this entry is about.
*/
const PROMPT = [
'Read the invoice below and return its header fields.',
'Answer with JSON only: an object with the keys `invoice_number`, `date`,',
'`total_excluding_vat`, `vat` and `total`. `total` is the amount due, taxes',
'included, as a number.',
'Use null for a field the page does not carry.',
'',
'Extracted text:',
].join('\n');
// The provider bills every token of the prompt: the text is capped before the
// call, in characters (code points, as in Python), not tokens. An image is
// billed by its dimensions, not its bytes, so its cap only bounds what is
// encoded and uploaded.
export const MAX_CHARACTERS = 8000;
export const MAX_IMAGE_BYTES = 4_000_000;
const FIELDS = ['invoice_number', 'date', 'total_excluding_vat', 'vat', 'total'];
const AMOUNTS = ['total_excluding_vat', 'vat', 'total'];
// The provider named here is an example, not a recommendation: the reasoning
// holds for any general-purpose model API, and the client is swappable. Pass
// any object with a `complete({ prompt, imageUrl, temperature })` method.
export const MODEL = 'gpt-4.1-mini'; // an example id: check the parameters your model accepts
export async function providerClient(sdk, model = MODEL) {
if (!sdk) {
const { OpenAI } = await import('openai');
sdk = new OpenAI();
}
return {
async complete({ prompt, imageUrl, temperature }) {
const response = await sdk.chat.completions.create({
model,
// The page travels inside the message, as a data URL.
messages: [{ role: 'user', content: [
{ type: 'text', text: prompt },
{ type: 'image_url', image_url: { url: imageUrl } },
] }],
temperature,
});
return response.choices[0].message.content;
},
};
}
export class ExtractionUnavailable extends Error {}
/**
* Read the invoice fields from its text and a picture of the page.
*
* @param {string} text the invoice, already extracted
* @param {Uint8Array} pageImage the rendered page; this snippet opens no file
* @param {object} options
* @param {{complete: Function}} [options.client] injected so this can be
* tested without a network call; defaults to a real provider client
* @param {number} [options.attempts]
*/
export async function extractFields(text, pageImage, { client, attempts = 3 } = {}) {
if (text.length > MAX_CHARACTERS && [...text].length > MAX_CHARACTERS) {
throw new RangeError(`text longer than ${MAX_CHARACTERS} characters`);
}
if (pageImage.length === 0 || pageImage.length > MAX_IMAGE_BYTES) {
throw new RangeError(`page image empty or larger than ${MAX_IMAGE_BYTES} bytes`);
}
client ??= await providerClient();
const imageUrl = `data:image/png;base64,${Buffer.from(pageImage).toString('base64')}`;
return decode(await ask(client, text, imageUrl, attempts));
}
async function ask(client, text, imageUrl, attempts) {
let lastError;
for (let i = 0; i < attempts; i += 1) {
try {
const answer = await client.complete({
prompt: `${PROMPT}\n${text}`,
imageUrl,
// Temperature zero, because an amount that changes between two
// identical calls cannot be reconciled with anything.
temperature: 0,
});
if (typeof answer === 'string') return answer;
lastError = new Error('the model returned no content'); // a refusal: unusable, not empty
} catch (error) {
lastError = error;
}
}
throw new ExtractionUnavailable(String(lastError));
}
/**
* Turn the answer into the three fields, or refuse it.
*
* Models like to wrap JSON in a code fence. That is noise, not an error, and
* stripping it is cheaper than another call.
*/
/**
* BR-CO-15 recomputed on what the model wrote, or null if it wrote too little.
*
* False is not a reason to throw the answer away: it is the one reason the
* caller has to look at this invoice rather than the other four hundred.
*/
function totalsAgree(fields) {
const [without, vat, total] = AMOUNTS.map((field) => fields[field]);
if (without === null || vat === null || total === null) return null;
return Math.abs(without + vat - total) < 0.005;
}
function decode(answer) {
const trimmed = answer.trim();
const fenced = trimmed.startsWith('```') && trimmed.endsWith('```') && trimmed.split('```').length === 3;
const stripped = fenced ? trimmed.slice(3, -3).replace(/^json/, '') : trimmed;
let parsed;
try {
parsed = JSON.parse(stripped);
} catch (error) {
throw new ExtractionUnavailable(`the model did not answer with JSON: ${error.message}`);
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new ExtractionUnavailable('the model answered something that is not an object');
}
const fields = Object.fromEntries(FIELDS.map((field) => [field, parsed[field] ?? null]));
for (const field of ['invoice_number', 'date']) {
if (fields[field] !== null && typeof fields[field] !== 'string') {
throw new ExtractionUnavailable(`the model answered a ${field} that is not text: ${fields[field]}`);
}
}
for (const field of AMOUNTS) {
// An amount nobody can compute with is worse than none at all: it would
// travel down the pipeline looking like a number.
if (fields[field] !== null && (typeof fields[field] !== 'number' || !Number.isFinite(fields[field]))) {
throw new ExtractionUnavailable(`the model answered a ${field} that is not a number: ${fields[field]}`);
}
}
return { ...fields, totals_agree: totalsAgree(fields) };
}What this means for you
- Your data goes to a third party
- The result varies from one call to the next
- Hard to test
- Depends on an outside provider
- High footprint
What this adds to your regulatory scope
- Transfer of invoices to a processor, with the contractual framing that implies
- Processing location to be confirmed with the provider
- Does not remove your own retention and audit-trail duties on accounting records
Breaking point
Every check here is a check on the shape of the answer, none on its truth. A perfectly valid object whose total appears nowhere on the invoice gets through, and the code cannot tell: telling would mean finding the total in the page itself, which is the work this level was picked to avoid.
When to move up a level
There is no level above this one.
Further reading
- Service-Public Entreprendre — Mentions obligatoires sur une facture
- LayoutLMv3 — Pre-training for Document AI with Unified Text and Image Masking
- microsoft/layoutlmv3-base — le point de contrôle qui fermerait le niveau N2 si sa licence le permettait
- Factur-X — la facture hybride PDF et XML, un des trois formats du socle
- impots.gouv.fr — à partir de quand suis-je concerné par la réforme de la facturation électronique ?
- impots.gouv.fr — spécifications externes et normes de la facturation électronique
Where the figures come from
- Service-Public Entreprendre — mention obligatoire de l’indemnité forfaitaire pour frais de recouvrement, réclamable à un client professionnel
- Hugging Face — carte de microsoft/layoutlmv3-base : licence CC BY-NC-SA 4.0
- Transformers — documentation LayoutLMv3 : processeur, mots et boîtes sur la grille 0-1000, 512 positions de jetons
- Transformers.js — liste des architectures prises en charge, sans LayoutLM
- OpenAI — Images and vision : image en URL de données, facturation par dimensions, formats acceptés
- impots.gouv.fr, foire aux questions « Je découvre la facturation électronique », version du 01/09/2026, question 5.1 : « Depuis le 1er septembre 2026, toutes les entreprises, quelles que soient leur taille et leur forme juridique, ont l’obligation de recevoir des factures sous format électronique, par l’intermédiaire d’une plateforme agréée » ; les grandes entreprises et les ETI doivent aussi les émettre, les petites, moyennes et micro-entreprises « au plus tard le 1er septembre 2027 »
- impots.gouv.fr, dossier de spécifications externes de la facturation électronique v3.2 (30/04/2026), section 2 : « le respect du socle minimum de formats reposant sur des standards sémantiques et syntaxiques respectant la norme européenne EN16931 pour faciliter les échanges : UBL, CII et Factur-X »
- Même dossier, annexe 1 « Format sémantique FE e-invoicing », chemins CII lus par l’extrait N0 : `/rsm:CrossIndustryInvoice/rsm:ExchangedDocument/ram:ID` (BT-1), `…/ram:IssueDateTime/udt:DateTimeString` (BT-2), `…/ram:SpecifiedTradeSettlementHeaderMonetarySummation/ram:TaxBasisTotalAmount` (BT-109) et `…/ram:TaxTotalAmount` (BT-110)
- Même dossier, annexe 7 « Règles de gestion » v1.9, règle BR-CO-15 : « Montant total de la facture TVA comprise (BT-112) = Montant total de la facture hors TVA (BT-109) + Montant total de TVA de la facture (BT-110) » ; la même annexe note qu’« en profil extended, la règle BR-CO-15 ne s’applique pas »
Metadata
General information, kept up to date as best we can. It is not legal advice. Have a professional review your situation.