Anyone who bills from orders, helpdesk tickets and time tracking in Odoo knows the result: at the end of the month, a customer gets not one invoice but five. Each comes from a different order or ticket, each is correct in substance – and yet the customer asks whether it couldn't all fit on one document.
There is no clean solution for exactly this in Odoo 19 Community. The well-known OCA module account_invoice_merge has not yet been released for Odoo 19, and it solves the problem in a way that poses a serious risk for service providers: identical lines are combined, and with that the link back to the order, the task and the recorded time is lost.
For our own Odoo 19 Community installation, we have therefore developed the module Merge Draft Invoices. Because this problem does not affect only us, we are making it available free of charge on GitHub under the free AGPL-3 licence.
To the project on GitHub:
Why Our Own Module Instead of the OCA Solution?
The OCA has been maintaining account_invoice_merge for many years in the OCA/account-invoicing repository. At the time of this post, the migration to 19.0 is still an open pull request there, not a published module.
More importantly, however, the two modules do different things:
| OCA account_invoice_merge | Merge Draft Invoices | |
|---|---|---|
| Result | is merged into one of the selected invoices | always a new invoice |
| Lines | identical lines are combined, quantities added | every line is carried over one to one |
| Origin | invoice_origin / payment_reference concatenated in the header | origin fields copied per line via Odoo's own _copy_data_extend_business_fields hook |
| Time tracking | not taken into account | timesheet_invoice_id is re-pointed to the new invoice |
| Readable for the customer | nothing | a heading above the lines of each source, with order and ticket |
| Source invoices | are cancelled | cancel (default), delete or keep – with clearly named consequences |
Combining identical lines is the decisive difference. A combined line can carry only one origin. As soon as two lines from two different orders look the same, the link from the invoice back to the order, the task and the time entry is gone. In a service business where billing runs via ticket → time tracking → invoice, this means: the same hours are billed twice or disappear.
Our module therefore does not combine anything as a matter of principle. The merged invoice has exactly as many lines as its sources taken together.
No code was taken from the OCA module. Both modules are licensed under AGPL-3.
What the Module Does
In the invoice list view, select two or more drafts and choose Merge draft invoices in the action menu – a wizard shows the selection, the checks and the options for the source invoices. After confirming, you land directly on the new invoice.
Before merging, the module runs its checks, all of them at once, so that you do not see the next error after every correction:
- at least two invoices selected
- all in Draft status
- none has ever been posted (a document reset to draft keeps its posting history and is rejected)
- none has already been merged
- all are an invoice, vendor bill, credit note or receipt – no pure journal entries
- same partner, document type, company, currency and journal
Differences in payment terms, fiscal position, delivery address, bank account or salesperson do not block the merge. The wizard warns and takes over the value of the oldest selected invoice.
What Is Preserved
The core of the module is that every link an invoice line has to its origin also exists on the new invoice:
| Link | Handling |
|---|---|
| Sales order lines (sale_line_ids) | copied per line via Odoo's _copy_data_extend_business_fields |
| Purchase order lines (purchase_line_id) | likewise |
| Cost centre distribution (analytic_distribution) | copied per line |
| other origin fields per line | automatically, if the supplying module implements the documented Odoo hook |
| Origin and reference in the invoice header | merged, deduplicated, editable in the wizard |
| Sales order: invoice_ids, invoice_status, qty_invoiced | are computed from the lines – the order recognises the new invoice by itself |
| Time tracking (timesheet_invoice_id) | is re-pointed to the new invoice before the sources become invalid |
| Helpdesk ticket | is attached to the time tracking and therefore automatically points to the new invoice |
| Chatter | message on the new invoice with all sources, message on each source with a link to the new one |
| merged_into_move_id / merged_from_move_ids | stored link in both directions |
Tax lines, payment term lines, rounding and cash discount lines are not copied but recomputed by Odoo from the content. The merged invoice therefore has one tax line per tax rate, not one per source. It is created as an unnumbered draft; the merge itself consumes no document number.
Headings: The Customer Sees Where Each Line Comes From
This is the function that makes the difference in everyday use. Above the lines of each source invoice, the module inserts a section line – the same element that Odoo itself uses for the “Down Payments” section:
BE10181 <- Heading
22,500 x Technician hour 3.577,50
Subtotal 3.577,50
BE10182 · Ticket: HT00219 <- Heading
3,000 x Technician hour 477,00
Subtotal 477,00
NET AMOUNT 4.054,50
The heading is printed on the document the customer receives, and Odoo's standard invoice report adds a subtotal per section by itself. The customer thus also sees what each part cost.
The label is assembled from what the source actually has:
- the orders, directly from the line links – the names are therefore exact; if order links are missing, the free text from invoice_origin is used
- the tickets, determined via time tracking – output as Ticket: HT00219 or Tickets: …, limited to five plus “and N more”
- if neither exists: the number of the source invoice, then its reference
- if there is nothing at all: a neutral “Further lines”, so that the lines of a source never silently disappear into the block above
What is missing is omitted, not invented. The ticket is printed with its own number – helpdesk_mgmt number, Enterprise ticket_ref or the display name of the model – never with the database ID.
The headings are written in the customer's language, just as Odoo does when generating invoices from orders. The chatter remains in the user's language.
A heading carries no product, quantity, price, tax, account or cost centre distribution. It cannot affect totals, tax base or cost accounting. Anyone who switches off the headings in the wizard gets exactly the invoice that would have been created without this function – a test compares both results down to the cent.
What Happens to the Source Invoices
The wizard offers three options:
- Cancel (default, recommended). Cancelling a draft has no consequences: no document number, no posting. The invoices remain in the database as cancelled documents with chatter and a link to the new invoice. qty_invoiced on the sales order skips cancelled documents, nothing is counted twice. This is the option that is still traceable six months from now.
- Delete. Irreversible. Chatter and back-reference die with the record; only the message on the new invoice remains. Sensible for throwaway drafts that nobody should see any more.
- Leave as draft. The sources remain open. Their sales orders then count the source and the new invoice as invoiced – the work looks as if it has been billed twice until someone cleans up the drafts by hand. Intended for the rare case where you want to compare before deciding. A test records exactly this double counting so that it does not surprise anyone.
Limits – What the Module Deliberately Does Not Do
Please read before production use:
- Drafts only. Posted invoices are not merged. That is a credit-note task, not a copy operation.
- Drafts that were posted once and then reset are rejected. They have a number and a posting history; hiding that in another document is nothing a bulk action should do silently.
- No merging across partners, currencies, companies or journals. There is no sensible single value for this, so the module refuses instead of guessing.
- payment_reference is not carried over. Structured payment references identify a document; two concatenated together match nothing at the bank. The new invoice gets its own when it is posted.
- Down payment lines are copied like any other line. If the sources contain down payment lines from sale, the result should be checked before posting.
- Only timesheet_invoice_id is re-pointed automatically. Every other model with a Many2one to account.move (own billing runs, subscription links, EDI transmissions) continues to point to the source. The _merge_draft_relink hook exists for this.
- No combining of identical lines – deliberately. Anyone who wants a short, tidy invoice is in the wrong place here and would pay for it with the origin links.
- Headings are created once, at the time of merging. After that they are ordinary section lines: editable, deletable and not updated if an order or ticket is renamed later.
- A heading names the tickets of the entire source invoice, not of the individual line.
Installation in Odoo 19 Community
Installation works as with any usual Odoo add-on module:
- Copy the module directory into a configured addons path.
- Update the app list in Odoo.
- Install the account_invoice_merge_draft module.
The module depends only on account. Everything it knows about sale, purchase, project, hr_timesheet, sale_timesheet or a helpdesk module is determined at runtime – it installs and works even on a pure accounting database.
Then: Accounting → Customers → Invoices (or Vendors → Bills), select two or more drafts, gear menu → Merge draft invoices. The action is available to all users with the Billing / Invoicing group.
Anyone upgrading from an earlier version of the module should update once with -u account_invoice_merge_draft --i18n-overwrite so that changed field labels arrive in the German translation. A fresh installation is not affected.
For Developers: API and Hooks
Merging can also be called from your own code:
python
merged = draft_moves.merge_draft_moves(
extra_vals={"invoice_date": date(2026, 3, 31), "ref": "Q1"},
source_handling="cancel", # "delete" or "keep"
add_source_sections=True, # headings; default True
)
merge_draft_moves raises a UserError with all rejection reasons if the selection cannot be merged. _merge_draft_get_errors() returns the same reasons as a list without an exception.
If you have your own back-references to account.move, override _merge_draft_relink:
python
class AccountMove(models.Model):
_inherit = "account.move"
def _merge_draft_relink(self, new_move):
super()._merge_draft_relink(new_move)
if "my_move_id" not in self.env["my.model"]._fields:
return
self.env["my.model"].search([("my_move_id", "in", self.ids)]).write(
{"my_move_id": new_move.id}
)
If you have a back-reference on the invoice line, you do not need this module at all: Odoo's own hook account.move.line._copy_data_extend_business_fields is enough, and the field is carried over equally when merging, cancelling and duplicating. For other helpdesk modules or a different wording of the headings, there are _merge_draft_get_source_tickets() and _merge_draft_section_name().
Tested Before It Runs on Production Data
48 tests cover mixed statuses, different partners, currencies and document types, lines with and without a sales or purchase order, taxes, sections and notes, cost centre distributions, the re-pointing of time tracking, all three options for the source invoices, the wizard and the headings – that they are active by default, that switching them off reproduces the old result down to the cent, that a heading carries no amounts or accounts, that the lines of a source stay together, that the ticket appears with its number and never with the database ID.
The test data builds on the existing chart of accounts of the active company rather than on AccountTestInvoicingCommon – that helper often fails to start on migrated production databases, and those are exactly the ones the module is meant to run on.
odoo-bin -d <db> -u account_invoice_merge_draft --test-enable \
--test-tags /account_invoice_merge_draft --stop-after-init
As with any extension in the accounting area, we recommend testing the installation first in a test environment with a copy of your own database.
Free and Open Source for the Odoo Community
At Biricon IT Services GmbH, we develop solutions first for specific requirements from our own operations. If this results in a generally usable module, we do not want to hide it away in a single installation.
Merge Draft Invoices is therefore available free of charge as an open-source project. The source code can be viewed, reviewed, adapted and further developed within the terms of the AGPL-3 licence.
Our aim was not the shortest possible invoice, but an invoice on which, even a year from now, every line still leads back to its order, its ticket and its time entry.
Project, Source Code and Contributing
The complete source code, the installation instructions and the technical documentation are available on GitHub:
Open Odoo 19 Merge Draft Invoices on GitHub
We welcome practical experience, bug reports, suggestions for improvement and contributions from the Odoo Community.
Free. Traceable. Open source. For Odoo 19 Community.