A few days ago I wrote about recovering an Odoo database after restoring from an old backup. That problem was big and visible: some invoices and customers no longer existed in the restored database. We had to rebuild invoices from emailed PDFs, recreate them via ORM, and verify that numbers, amounts, taxes, and attachments were all correct.
Today a smaller version of the same theme showed up.
The invoice was in Odoo. The accounting entry existed. The customer existed. The amount was paid. From inside the system, everything looked fine.
But the public invoice link was broken.
The symptom
The invoice was INV/2025/00001, from MCS DataLabs, for €1,624.35.
The portal link was supposed to let the customer download the PDF. Instead, it returned a 0-byte file.
It's easy to underestimate that detail. In Odoo, an invoice can be posted and paid, but to the customer the real invoice is the PDF they receive or download. If that PDF is empty, the document is broken even if the accounting entry is perfect.
There was another bad sign: the payment reference pointed to INV/2025/00002 instead of INV/2025/00001.
This wasn't a cosmetic issue. It was a public invoice with an empty PDF and a wrong reference.
First fix: regenerate the PDF
The first fix was straightforward:
- generate the authenticated PDF from Odoo
- update the attachment attached to the invoice
- correct
refandpayment_referencetoINV/2025/00001 - test the public link again
The verification was concrete:
- the public PDF no longer weighed 0 bytes
- it contained
INV/2025/00001 - it did not contain
INV/2025/00002 - it showed
MCS DataLabs - it showed the correct total: €1,624.35
That seemed resolved.
But there was another layer.
Second fix: the right language
The partner needed the invoice in German. I changed the customer language to de_DE and regenerated the PDF.
The content came out in German: Rechnung, not Factura.
The reference was still right. The total was still right. The number was still right.
But the PDF still looked wrong. It came out without formatting.
That's when the real problem showed up: it wasn't enough for the PDF to exist. wkhtmltopdf was generating the report without web.report_assets_common because a report asset pointed to a file that no longer existed in the filestore.
The hidden problem: broken report assets
Odoo doesn't always fail with a clean traceback. Sometimes it generates a valid PDF with correct text but no CSS.
That's useful for tricking you. The document has the invoice number, the customer, and the total. If you only extract text, it looks fine. But visually it's not a presentable invoice: no real formatting, the table breaks, totals don't align, and the document can't be sent to a customer.
In this case there were three layers:
- first, the public PDF downloaded as 0 bytes
- then, the invoice had
refandpayment_referencepointing toINV/2025/00002 - then, the PDF came out in German, but without CSS
The final fix was:
- clean up obsolete PDF/report assets
- let Odoo regenerate
web.report_assets_common - delete the previous attachment from the invoice
- regenerate the PDF
- verify the result visually and textually from the public link
The first broken attachment was ir.attachment #444. The intermediate regeneration left #4080, still without formatting. The good attachment ended up as #4088.
The final version had formatting. And it was still in German.
What I learned from this invoice
In the previous article, the problem was recovering lost data after an old backup. This time the data was there, but the public artifact was broken.
It's another form of the same lesson: in business systems, "the record exists" does not mean "the process is correct."
An invoice has several lives:
- the
account.movein Odoo - the accounting lines
- the payment reference
- the cached PDF attachment
- the portal public link
- the attachment Odoo may reuse
- the report assets
wkhtmltopdfuses - the partner language
- the PDF the customer sees
If you only check the first one, you can declare victory while the customer downloads garbage.
The right test
After touching a posted invoice, looking at the backend is not enough.
The minimum test should be:
- open the public link, not just the backend
- download the PDF from the portal
- check the file is a reasonable size
- extract text and look for the correct number
- look for absence of wrong references
- check customer, total, and language
- visually check the PDF has formatting, not just plain text
In this case, the final verification looked like this:
- valid public PDF
- correct number:
INV/2025/00001 - no trace of
INV/2025/00002 - correct customer:
MCS DataLabs - correct total: €1,624.35
- German language:
Rechnung - public PDF size: 28,934 bytes
- correct visual formatting: logo, table, aligned totals, and footer
That last point matters. A PDF without CSS can pass a text check and still be unacceptable to send to a customer.
The uncomfortable part
Odoo doesn't always fail with a clear error.
Sometimes it fails with a 0-byte PDF.
Sometimes it generates the PDF, but with an old reference.
Sometimes it generates the PDF in the right language, but without CSS.
Each of those layers needs a different test. If you don't, you fix the first symptom and leave the second one alive.
The continuity with the old backup
The old backup incident left a clear rule: a backup is not valid until you restore it and check.
This invoice leaves a similar rule: an invoice is not good until the document the customer sees is downloaded, opened, and verified.
In Odoo, data and documents are not exactly the same thing. The data can be correct and the PDF broken. The PDF can exist and be in the wrong language. The language can be right and the CSS broken.
That's why the final evidence has to be at the edge of the system: right where the customer touches it.
Not "the invoice exists."
The customer can open the public link, download a valid PDF, read the correct invoice, and see it properly formatted.
That's when it's fixed.