An amount in PDF text has no edges
“An imported invoice came through with a total of 190.00 when the PDF says 1190.00, and the importer's own sum check passed it.”
Picture a supplier invoice: net 1000.00, VAT 190.00, total 1190.00, printed without a thousands separator. An importer reads the PDF's text, pulls out the three amounts and checks that net plus VAT equals the total before handing the result to a person to confirm. It reports a net of 0.00, VAT of 190.00 and a total of 190.00. The check passes, because 0 + 190 = 190.
Nothing in that pipeline is careless. The numbers came from the document, the code validated them, and the arithmetic is correct. The problem is that a number in a PDF's extracted text is a run of characters with no edges, and the parser has to decide where it begins, where it ends and which separator is the decimal point. Get any of the three wrong and the result is still a number.
Two parsers, one missing edge
The invoice above is not hypothetical. It is the test case in SanjoSolutions/accounting#47, opened on 13 October, and the parser it describes reads amounts with this pattern:
/(\d{1,3}(?:[.,]\d{3})*[.,]\d{2})\s*EUR/gi
The integer part is one to three digits, optionally followed by groups of three after a
separator. That accepts 1,000.00 and 1.000,00. It does not accept 1000.00, and
nothing stops the match from starting in the middle of it. A regular expression engine
tries each starting position from left to right and takes the first one that works. At
the 1, the pattern consumes 100, needs a separator and finds a 0, and gives up. At
the next position it consumes 000, finds .00, and succeeds. The amount it returns is
000.00.
Run as written in Node 24, the parser returns netAmountCents: 0, taxAmountCents: 19000, grossAmountCents: 19000 for that text, exactly as the issue reports. A net of 2500.00
comes back as 500.00. A net of 12345.67 comes back as 345.67.
The same shape turns up in Odoo's community invoice importer,
account_invoice_import_simple_pdf
from the OCA. When a field has no custom pattern, it builds one from the partner's
separators:
pattern = rf"(?:\d{{1,3}}{thousand_sep_pattern})*\d{{1,3}}{decimal_sep_pattern}\d{{{decimal_places}}}"
The thousands separator falls back to the partner's language, and the default value of
that setting on an Odoo language record is a comma. With a comma configured, the same text yields
the candidates 000.00, 190.00 and 190.00. The module's default rule for the total is
to take the largest candidate, so the total comes back as 190.0. Its default rule for
the untaxed amount is the third candidate from the end, which is 0.0. Untaxed 0, total
190: the same consistent wrong answer, reached independently.
That second result is measured here, not reported anywhere we found. Neither issue mentions it, and the module's own amount-parsing tests do not include an ungrouped amount with a thousands separator configured.
Why the sum check agrees
The check in the first parser looks like the right defence. If any of the three amounts were misread, surely net plus VAT would stop equalling the total.
It does not, because of what the misreading does. Starting the match late drops whole thousands from the front of the number, and it drops them from the net and the total alike. Write the net as some thousands plus a remainder, and the VAT the same way. The total is their thousands added together plus the two remainders added together. Strip the thousands from all three and the remainders still add up, unless adding the VAT carried into the thousands digit.
So the check catches the error only when there is a carry. We ran the unmodified parser over every net from 1000.00 to 9999.99 in cent steps, with 19% VAT and no thousands separators: 48.4% of those 900,000 invoices were accepted with wrong amounts. The rest returned nothing, which is the safe failure, and is why this bug would look intermittent in practice.
A check computed from the parser's output cannot catch an error that the parser applies the same way to every amount. It needs evidence the parser did not produce, such as the invoice's line items, or simply a person comparing the extracted figure with the page.
The other edge: where the decimal point is
The second parser has a separate problem on the right-hand side of the number, reported in OCA/edi#1423 and confirmed there by a second contributor on Odoo 18.0. After removing thousands separators, the code finds the decimal point by position:
if decimal_places:
amount_raw_list = list(amount_raw)
amount_raw_list[-decimal_places - 1] = "."
amount_raw = "".join(amount_raw_list)
With two decimal places that overwrites the third character from the end. It is right
when the match has exactly two decimals, which the default pattern guarantees. It goes
wrong as soon as a custom pattern admits anything else, and the reporter needed one,
because the receipts they import print the total as 302.59 some months and 302.6 in
others.
Run verbatim:
| text | after the overwrite | result |
|---|---|---|
302.59 |
302.59 |
302.59 |
302.6 |
30..6 |
not a number, dropped, logged at debug level |
302 |
0.02 |
0.02, no error |
The fix proposed in the issue replaces the decimal separator rather than writing to a
fixed position. Run on the same inputs it returns 302.6 and 302.0. It does not change the
ungrouped 1190.00 case above, which still comes back as 190.0, because that error
happens earlier, in the pattern, before any conversion runs.
Both versions of the code pass every case in the module's own amount-parsing test, 54 inputs once the space-separator variants are expanded. Neither failure is in that test, which is how both shipped.
Loosening the pattern lets other numbers in
The obvious response is to make the pattern more permissive: optional decimals, any number of digits. The issue suggested making the decimals optional, and the confirming comment explains why that is not free:
On
akretion_france-test.pdfoptional decimals turn the page number into a candidate, solastreturns 1.0 instead of 1810.80, and the VAT number gives 923, which beats any smaller total undermax.
That is the trade every one of these parsers is making. A strict pattern misses amounts written in a way it did not expect. A loose one finds amounts that are not amounts: page numbers, VAT registration numbers, dates, quantities. The text layer gives the parser no help in telling them apart, because it carries no types, only characters in reading order.
What gives an amount its edges
Three things, all visible in the two issues.
Bound the match on both sides. A number should not be allowed to start immediately after a digit or a separator, or to end immediately before one. One way to write that for the first parser:
/(?<![\d.,])(\d{1,3}(?:[.,]\d{3})+[.,]\d{2}|\d+[.,]\d{2})(?![\d.,]\d)\s*EUR/gi
With that pattern in place of the original, the parser returned exactly the right net, VAT
and total for all 900,000 invoices in the sweep above, and for the grouped English and
German forms. It is an illustration rather than a recommendation: it still assumes two
decimal places and a trailing EUR, which is this parser's contract, not every
invoice's.
Convert by meaning, not by position. Decide which character is the decimal separator, replace it, and parse what is left. That is the fix in #1423, and it gives the same answers as the original wherever the original was right.
Refuse what cannot be read safely. The first issue's acceptance criteria say it directly: reject ambiguous or malformed amounts "rather than accepting a numeric suffix", and keep the human review step. An importer that returns nothing for an invoice costs someone a minute. One that returns a confident 190.00 costs whatever happens after it is paid.
One detail in the first parser is worth a look on its own: every successful extraction reports a confidence of 0.85 for each amount. The number is a constant in the source, so the wrong total and the right one arrive with the same score. A reviewer who learns to trust high-confidence fields is trusting a literal.
What was and was not checked
Both parsers were run from their published source, not from the issues' descriptions:
SanjoSolutions/accounting at the commit the issue cites, 9228633, and
account_invoice_import_simple_pdf from the OCA/edi 18.0 branch, whose amount code is
the same as 16.0 apart from string formatting. The TypeScript parser ran in Node 24.19.0.
The Python function ran in CPython 3.12.3, under both the regex package the module
imports (2026.9.29) and the standard re module, and the patterns at the centre of this
post were also run in Node's engine. All three engines gave the same matches on every
input above.
The inputs were plain text, as in both issues, so the PDF text extraction step was not exercised. A real PDF can add its own problems before any of this, such as amounts split across text runs. The 48.4% figure is specific to 19% VAT, ungrouped nets between 1000.00 and 9999.99, and this one parser; it is a measure of how often the check misses, not of how often anyone receives such an invoice. The page-number and VAT-number result is the confirming contributor's, quoted from the issue, and was not reproduced here.
Nothing here is a claim about how often this happens in practice. This post makes no claim about our own products.