Enable Dark Mode!
how-to-add-filters-in-the-accounting-report-odoo-19.jpg
By: Renu M

How to Add Filters in the Accounting Report Odoo 19

Technical Odoo 19 Accounting

The redesigned account_reports module, which powers Odoo 19's Accounting Reports engine, is one of the framework's most potent but least documented expansion points. It powers all of the financial reports that your accountants use on a regular basis, including the Tax Report, the Balance Sheet, and the Profit & Loss Statement. Even though the engine comes with a wide range of built-in filters (date range, journals, analytical accounts, partners, fiscal positions, and more), practical applications nearly always require an additional filter.

Think of a manufacturing business that labels bills according to intercompany relationships, product lines, or projects. No out-of-the-box filter can accommodate their accountants' demand to slice the P&L by such identifiers. Exporting to Excel and filtering there is a simplistic method that undermines auditability and negates the purpose of an ERP.

Extending the account is the best course of action.report using an appropriate custom filter. Using the fully functional module invoice_tag_report_filter as a real example, this article walks you through just that. By the conclusion, you will comprehend:

  • The complete workflow of Odoo 19's report options pipeline
  • How to tie a newly created master-data model to account.move
  • How to hook into the server-side _init_options and _get_options_domain
  • How to use the OWL frontend to register a custom filter component
  • How to connect the XML template so that the filter bar's dropdown menu displays

Module Overview

invoice_tag_report_filter/
+-- __manifest__.py
+-- __init__.py
+-- models/
¦ +-- __init__.py
¦ +-- invoice_tag.py # New model: invoice.tag
¦ +-- account_move.py # Adds invoice_tag_id to account.move
¦ +-- account_report.py # Extends account.report with filter logic
+-- views/
¦ +-- invoice_tag_views.xml # Form, list, action, menu for invoice.tag
¦ +-- account_move_views.xml # Injects tag field into invoice form
¦ +-- account_report_views.xml# Exposes the filter toggle on report config
+-- security/
¦ +-- ir.model.access.csv # Read/write rules for invoice.tag
+-- static/src/account_report/
+-- account_report_filter.js # OWL component registration
+-- account_report_filter.xml# OWL template (dropdown + filter bar patch) 

The Manifest

The __manifest__.py is straightforward but has two sections worth calling out: the data list and the assets block.

# -*- coding: utf-8 -*-
{
    'name': "Invoice Tag Report Filter",
    'summary': """
        Adds an Invoice Tag field on account.move and a corresponding
        filter on Accounting Reports (P&L, Balance Sheet, etc.).
    """,
    'description': """
        * New model  : invoice.tag
        * New field  : account.move.invoice_tag_id  (Many2one ? invoice.tag)
        * New filter : Filter Invoice Tag on account.report
        Enable the filter by checking 'Filter Invoice Tag' on the
        Accounting Report form view.
    """,
    'author': "Renu M",
    'category': 'Accounting',
    'version': '19.0.1.0.0',
    'depends': ['base', 'web', 'account', 'account_reports', 'account_accountant'],
    'data': [
        'security/ir.model.access.csv',
        'views/invoice_tag_views.xml',
        'views/account_move_views.xml',
        'views/account_report_views.xml',
    ],
    'assets': {
        'web.assets_backend': [
            'invoice_tag_report_filter/static/src/account_report/account_report_filter.xml',
            'invoice_tag_report_filter/static/src/account_report/account_report_filter.js',
        ],
    },
    'license': 'LGPL-3',
}

The security CSV must come first in data so that by the time Odoo tries to create invoice.tag records during demo data loading, access rights already exist. The assets block registers OWL components into the backend bundle without this the JS file would simply be ignored.

The invoice.tag Model

Every meaningful filter needs something to filter by. Here we introduce a lean master-data model that accountants can manage from the Accounting > Configuration menu.

# -*- coding: utf-8 -*-
from odoo import models, fields

class InvoiceTag(models.Model):
    """
    Simple master-data model used to categorise journal entries / invoices.
    Example tags: "Project-A", "Intercompany", "Recurring", etc.
    """
    _name = 'invoice.tag'
    _description = 'Invoice Tag'
    _order = 'name'
    name = fields.Char(string='Tag Name', required=True)
    active = fields.Boolean(default=True)
    color = fields.Integer(string='Color Index')
    note = fields.Text(string='Notes')
    _sql_constraints = [
        ('name_uniq', 'unique(name)', 'Invoice Tag name must be unique.'),
    ]

Key design decisions:

  • active field — gives accountants a soft-delete capability via the standard Odoo archive mechanism.
  • colour field — the Integer color index is consumed by the color_picker widget in the form view, making tags visually identifiable.
  • _sql_constraints — enforces uniqueness at the database level, not just via Python validation, so concurrent saves cannot create duplicates.
  • _order = 'name' — ensures dropdowns and list views are always alphabetically sorted without extra effort.

Access Rights

The security CSV grants read access to accounting users and full CRUD to accounting managers:

id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_invoice_tag_user,invoice.tag user,model_invoice_tag,account.group_account_user,1,0,0,0
access_invoice_tag_manager,invoice.tag manager,model_invoice_tag,account.group_account_manager,1,1,1,1

This follows Odoo's convention: users can see and pick tags (read = 1); only managers can create, rename, or delete them. Granting write to ordinary users would create a maintenance headache as every accountant starts inventing their own tags.

Views and Menu

The invoice_tag_views.xml registers a standard form + list view and a menu item under Accounting > Configuration:

<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
    <data>
        <!-- ============================================================ -->
        <!-- Form view                                                     -->
        <!-- ============================================================ -->
        <record id="invoice_tag_form_view" model="ir.ui.view">
            <field name="name">invoice.tag.form</field>
            <field name="model">invoice.tag</field>
            <field name="arch" type="xml">
                <form string="Invoice Tag">
                    <sheet>
                        <group>
                            <group>
                                <field name="name"/>
                                <field name="color" widget="color_picker"/>
                            </group>
                            <group>
                                <field name="active"/>
                            </group>
                        </group>
                        <group string="Notes">
                            <field name="note" nolabel="1"/>
                        </group>
                    </sheet>
                </form>
            </field>
        </record>
        <!-- ============================================================ -->
        <!-- List view                                                     -->
        <!-- ============================================================ -->
        <record id="invoice_tag_list_view" model="ir.ui.view">
            <field name="name">invoice.tag.list</field>
            <field name="model">invoice.tag</field>
            <field name="arch" type="xml">
                <list string="Invoice Tags">
                    <field name="name"/>
                    <field name="active"/>
                </list>
            </field>
        </record>
        <!-- ============================================================ -->
        <!-- Window action                                                 -->
        <!-- ============================================================ -->
        <record id="invoice_tag_action" model="ir.actions.act_window">
            <field name="name">Invoice Tags</field>
            <field name="res_model">invoice.tag</field>
            <field name="view_mode">list,form</field>
            <field name="help" type="html">
                <p class="oe_view_nocontent_create">
                    Click to create a new Invoice Tag.
                </p>
            </field>
        </record>
        <!-- ============================================================ -->
        <!-- Menu item -- placed under Accounting > Configuration           -->
        <!-- ============================================================ -->
        <menuitem
            id="invoice_tag_menu"
            name="Invoice Tags"
            parent="account.account_account_menu"
            action="invoice_tag_action"
            sequence="99"/>
    </data>
</odoo>

Adding the Tag Field to account.move

The Many2one field is added via a simple model extension:

# -*- coding: utf-8 -*-
from odoo import models, fields

class AccountMove(models.Model):
    _inherit = 'account.move'
    invoice_tag_id = fields.Many2one(
        comodel_name='invoice.tag',
        string='Account Tag',
        ondelete='set null',
        tracking=True,
        help="Categorise this invoice / journal entry with a custom tag "
             "for reporting purposes.",
    )

Two attributes deserve special attention:

  • ondelete='set null' — if someone deletes a tag that is already assigned to thousands of invoices, Odoo sets invoice_tag_id to False on those invoices rather than raising an integrity error. This is almost always the correct behaviour for classification tags.
  • tracking=True — every change to the tag is recorded in the chatter. Auditors will thank you.

The corresponding view injection places the field just after the ref field in the standard invoice header:

<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
    <data>
        <!-- ============================================================ -->
        <!-- Inherit account.move (invoice) form view                     -->
        <!-- ============================================================ -->
        <record id="account_move_form_inherit_invoice_tag" model="ir.ui.view">
            <field name="name">account.move.form.inherit.invoice.tag</field>
            <field name="model">account.move</field>
            <field name="inherit_id" ref="account.view_move_form"/>
            <field name="arch" type="xml">
                <!-- Place after the journal field in the header area -->
                <xpath expr="//field[@name='ref']" position="after">
                    <field name="invoice_tag_id"
                           options="{'no_create_edit': True}"
                           placeholder="Select a tag..."/>
                </xpath>
            </field>
        </record>
    </data>
</odoo>

The no_create_edit option prevents users from creating or editing tags inline from the invoice form. They must go to the dedicated configuration menu. This keeps tag management controlled and avoids accidental tag proliferation.

Extending account.report

This is the heart of the module. The account_report.py file does three things: adds a boolean toggle to account.report, initialises the filter option, and extends the domain query.

# -*- coding: utf-8 -*-
from odoo import models, fields, _
from odoo.fields import Domain
from odoo.exceptions import UserError
from odoo import osv

class InheritAccountReport(models.Model):
    _inherit = 'account.report'
    filter_invoice_tag = fields.Boolean(string="Filter Invoice Tag")
    # ------------------------------------------------------------------ #
    # OPTIONS INITIALISATION
    # ------------------------------------------------------------------ #
    def _init_options_invoice_tag(self, options, previous_options=None):
        """
        Called automatically by account.report._init_options() because the
        method name follows the pattern  _init_options_<suffix>.
        Adds  'filter_invoice_tag'  and  'invoice_tag_ids'  to the options
        dict so the JS layer can serialise / restore the selection.
        """
        if not self.filter_invoice_tag:
            return
        options['filter_invoice_tag'] = True
        options['invoice_tag_ids'] = (
            previous_options.get('invoice_tag_ids') if previous_options else []
        ) or []
    # ------------------------------------------------------------------ #
    # DOMAIN OVERRIDE
    # ------------------------------------------------------------------ #
    def _get_options_domain(self, options, date_scope):
        """
        Extend the standard domain with the invoice-tag filter when active.
        The tag is stored on account.move; account.move.line has a
        many2one  move_id ? account.move, so we reach it via
        move_id.invoice_tag_id.
        """
        self.ensure_one()
        available_scopes = dict(
            self.env['account.report.expression']._fields['date_scope'].selection
        )
        if date_scope not in available_scopes:
            raise UserError(_("Unknown date scope: %s", date_scope))
        company_ids = (
            options.get('company_ids')
            or self.get_report_company_ids(options)
        )
        domain = Domain([
            ('display_type', 'not in', ('line_section', 'line_note')),
            ('company_id', 'in', company_ids),
        ])
        # ---- Invoice Tag filter ---------------------------------------- #
        if options.get('filter_invoice_tag') and options.get('invoice_tag_ids'):
            domain &= Domain([
                ('move_id.invoice_tag_id', 'in', options['invoice_tag_ids']),
            ])
        # ---- Standard report sub-domains -------------------------------- #
        domain &= Domain(self._get_options_journals_domain(options))
        domain &= Domain(self._get_options_date_domain(options, date_scope))
        domain &= Domain(self._get_options_partner_domain(options))
        domain &= Domain(self._get_options_all_entries_domain(options))
        domain &= Domain(self._get_options_unreconciled_domain(options))
        domain &= Domain(self._get_options_fiscal_position_domain(options))
        domain &= Domain(self._get_options_account_type_domain(options))
        if self.only_tax_exigible:
            domain &= Domain(
                self.env['account.move.line']._get_tax_exigible_domain()
            )
        return domain
    # ------------------------------------------------------------------ #
    # FISCAL POSITION HELPER  (copied verbatim from reference module)
    # ------------------------------------------------------------------ #
    def _get_options_fiscal_position_domain(self, options):
        def get_foreign_vat_tax_tag_extra_domain(fiscal_position=None):
            fp_ids_to_exclude = self.env['account.fiscal.position'].search([
                ('id', '!=', fiscal_position.id if fiscal_position else False),
                ('foreign_vat', '!=', False),
                ('country_id', '=', self.country_id.id),
            ]).ids
            if fiscal_position and fiscal_position.country_id == self.env.company.account_fiscal_country_id:
                fp_ids_to_exclude.append(False)
            return [
                ('tax_tag_ids.country_id', '=', self.country_id.id),
                ('move_id.fiscal_position_id', 'not in', fp_ids_to_exclude),
            ]
        fiscal_position_opt = options.get('fiscal_position')
        if fiscal_position_opt == 'domestic':
            domain = [
                '|',
                ('move_id.fiscal_position_id', '=', False),
                ('move_id.fiscal_position_id.foreign_vat', '=', False),
            ]
            tax_tag_domain = get_foreign_vat_tax_tag_extra_domain()
            return osv.expression.OR([domain, tax_tag_domain])
        if isinstance(fiscal_position_opt, int):
            domain = [('move_id.fiscal_position_id', '=', fiscal_position_opt)]
            fiscal_position = self.env['account.fiscal.position'].browse(fiscal_position_opt)
            tax_tag_domain = get_foreign_vat_tax_tag_extra_domain(fiscal_position)
            return osv.expression.OR([domain, tax_tag_domain])
        return []

6.1 The Boolean Toggle

filter_invoice_tag = fields.Boolean(string="Filter Invoice Tag")

This field is a per-report feature flag. An administrator can enable it on any accounting report (Profit & Loss, Balance Sheet, etc.) via the report’s configuration form. When True, the invoice-tag dropdown appears in the filter bar for that report. When False, the filter is completely ignored – no UI, no extra SQL, no performance overhead.

The view injection that exposes this toggle is minimal:

<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
    <data>
        <!-- ============================================================ -->
        <!-- Inherit account.report form -- expose the new boolean toggle   -->
        <!-- so users can enable the Invoice Tag filter per report.       -->
        <!-- ============================================================ -->
        <record id="account_report_form_inherit_invoice_tag" model="ir.ui.view">
            <field name="name">account.report.form.inherit.invoice.tag</field>
            <field name="model">account.report</field>
            <field name="inherit_id" ref="account_reports.account_report_form"/>
            <field name="arch" type="xml">
                <xpath expr="//field[@name='filter_partner']" position="after">
                    <field name="filter_invoice_tag"/>
                </xpath>
            </field>
        </record>
    </data>
</odoo>

_init_options_invoice_tag — How Options Are Initialised

Odoo 19's account.report._init_options() dynamically discovers all methods whose names follow the pattern _init_options_<suffix> and calls them in sequence. This means you never need to override _init_options() itself — you just add a correctly named method and Odoo wires it up automatically.

  def _init_options_invoice_tag(self, options, previous_options=None):
        """
        Called automatically by account.report._init_options() because the
        method name follows the pattern  _init_options_<suffix>.
        Adds  'filter_invoice_tag'  and  'invoice_tag_ids'  to the options
        dict so the JS layer can serialise / restore the selection.
        """
        if not self.filter_invoice_tag:
            return
        options['filter_invoice_tag'] = True
        options['invoice_tag_ids'] = (
            previous_options.get('invoice_tag_ids') if previous_options else []
        ) or []

What is happening step by step:

  • Guard clause — if this report has not enabled the feature flag, return immediately. This ensures zero overhead for all other reports.
  • options['filter_invoice_tag'] = True — signals to the OWL frontend that it should render the Invoice Tag dropdown in the filter bar.
  • options['invoice_tag_ids'] — carries the list of selected tag IDs. The previous_options fallback restores the user's selection when the page refreshes or when they navigate between reports and come back. This is the same pattern Odoo uses for all its native filters.

_get_options_domain — Applying the Filter to the Query

When a user selects one or more invoice tags and clicks Refresh, Odoo calls _get_options_domain() to build the final WHERE clause for all report lines. We override this method to inject our tag condition:

def _get_options_domain(self, options, date_scope):
        """
        Extend the standard domain with the invoice-tag filter when active.
        The tag is stored on account.move; account.move.line has a
        many2one  move_id ? account.move, so we reach it via
        move_id.invoice_tag_id.
        """
        self.ensure_one()
        available_scopes = dict(
            self.env['account.report.expression']._fields['date_scope'].selection
        )
        if date_scope not in available_scopes:
            raise UserError(_("Unknown date scope: %s", date_scope))
        company_ids = (
            options.get('company_ids')
            or self.get_report_company_ids(options)
        )
        domain = Domain([
            ('display_type', 'not in', ('line_section', 'line_note')),
            ('company_id', 'in', company_ids),
        ])
        # ---- Invoice Tag filter ---------------------------------------- #
        if options.get('filter_invoice_tag') and options.get('invoice_tag_ids'):
            domain &= Domain([
                ('move_id.invoice_tag_id', 'in', options['invoice_tag_ids']),
            ])
        # ---- Standard report sub-domains -------------------------------- #
        domain &= Domain(self._get_options_journals_domain(options))
        domain &= Domain(self._get_options_date_domain(options, date_scope))
        domain &= Domain(self._get_options_partner_domain(options))
        domain &= Domain(self._get_options_all_entries_domain(options))
        domain &= Domain(self._get_options_unreconciled_domain(options))
        domain &= Domain(self._get_options_fiscal_position_domain(options))
        domain &= Domain(self._get_options_account_type_domain(options))
        if self.only_tax_exigible:
            domain &= Domain(
                self.env['account.move.line']._get_tax_exigible_domain()
            )
        return domain

The points:

  • Odoo 19 uses the new odoo.fields.Domain class (not a raw list). The & operator merges two Domain objects, which is cleaner than osv.expression.AND.
  • The path move_id.invoice_tag_id traverses account.move.line > account.move > invoice.tag. Odoo's ORM resolves this join automatically. No raw SQL needed.
  • The guard if options.get('filter_invoice_tag') and options.get('invoice_tag_ids') means the filter is only applied when both the feature is enabled AND the user has actually selected at least one tag. An empty selection is treated as 'show everything'.
  • All existing sub-domains are preserved in the correct order, ensuring the custom filter composes cleanly with journals, dates, partners, fiscal positions, and tax exigibility

The OWL Frontend

Odoo 19's accounting report UI is built in OWL (Odoo Web Library), not legacy Widget/QWeb. Adding a UI filter requires two files: a JS component class and an XML template.

The JS Component

/** @odoo-module **/
import { AccountReport } from "@account_reports/components/account_report/account_report";
import { AccountReportFilters } from "@account_reports/components/account_report/filters/filters";
/**
 * AccountReportFilterInvoiceTag
 *
 * Thin sub-class of AccountReportFilters that links to the XML template
 * containing the Invoice-Tag dropdown.  Registered as a custom component
 * on AccountReport so Odoo's report controller picks it up automatically.
 */
export class AccountReportFilterInvoiceTag extends AccountReportFilters {
    static template = "invoice_tag_report_filter.AccountReportFilterInvoiceTag";
    setup() {
        super.setup();
    }
}
AccountReport.registerCustomComponent(AccountReportFilterInvoiceTag);

The module pattern here is deliberate:

  • We extend AccountReportFilters (not create a standalone component) so we automatically inherit all the state management methods — in particular getMultiRecordSelectorProps — which the XML template calls to wire up the tag selector.
  • static template points to the XML template by its full qualified name (module.TemplateName).
  • AccountReport.registerCustomComponent() is the official extension point provided by account_reports. It tells the report controller to mount our component alongside the built-in filter bar. This is the correct way to add UI in Odoo 19 — not patching the parent component directly.

The XML Template

The XML file contains two templates. The stand-alone dropdown widget is the first.

A built-in Odoo widget called MultiRecordSelector OWL creates a many2many-style tag selector. The frontend selection and the options dict that _init_options_invoice_tag initialized are connected by the call getMultiRecordSelectorProps('invoice.tag', 'invoice_tag_ids'), which specifies which model to search and which options key to read/write.

The second template adds our dropdown to the regular filter bar, but only if the server has added filter_invoice_tag to the options.

The directive t-inherit-mode='extension' is crucial. Without it, instead of patching the filter bar, the template would replace it completely. By ensuring that the dropdown only shows up when the server-side flag is active, the conditional t-if keeps the two layers in sync by ensuring that the JS always represents the Python setting.

<?xml version="1.0" encoding="UTF-8" ?>
<templates>
    <!-- ================================================================ -->
    <!-- Standalone Invoice-Tag dropdown widget                           -->
    <!-- ================================================================ -->
    <t t-name="invoice_tag_report_filter.AccountReportFilterInvoiceTag">
        <Dropdown menuClass="'account_report_filter partner'">
            <button class="btn btn-secondary">
                <i class="fa fa-tag me-1"/>
                Invoice Tag
            </button>
            <t t-set-slot="content">
                <div class="dropdown-item gap-2 align-items-center">
                    <label>Invoice Tag</label>
                    <!--
                        MultiRecordSelector serialises selected ids into
                        options['invoice_tag_ids'] automatically, matching
                        what _init_options_invoice_tag() reads server-side.
                    -->
                    <MultiRecordSelector
                        t-props="getMultiRecordSelectorProps('invoice.tag', 'invoice_tag_ids')"
                        domain="[]"
                    />
                </div>
            </t>
        </Dropdown>
    </t>
    <!-- ================================================================ -->
    <!-- Extend the standard filter bar to include our new dropdown       -->
    <!-- ================================================================ -->
    <t t-name="invoice_tag_report_filter.AccountReportFiltersCustomizable"
       t-inherit="account_reports.AccountReportFiltersCustomizable"
       t-inherit-mode="extension">
        <xpath expr="//div[@id='filter_rounding_unit']" position="before">
            <!-- Show only when the server has put 'filter_invoice_tag' in options -->
            <t t-if="'filter_invoice_tag' in controller.options">
                <div id="filter_invoice_tag">
                    <t t-call="invoice_tag_report_filter.AccountReportFilterInvoiceTag"/>
                </div>
            </t>
        </xpath>
    </t>
</templates>

8. Output

Follow these steps to get the module running:

Open Accounting > Reports > Profit & Loss. The Invoice Tag dropdown should appear in the filter bar. Select a tag and verify the report figures change.

How to Add Filters in the Accounting Report Odoo 19-cybrosys

Odoo 19's accounting reports are based on a well-thought-out extension structure. You can add filter initialization without modifying any core code by using the lightweight hook _init_options_<suffix>. Writing filter criteria is safe and readable thanks to the Domain class. Additionally, the OWL frontend offers clear extension points through registerCustomComponent and t-inherit, despite its initial steepness in comparison to the previous QWeb/Widget system.All of these components function together in the invoice_tag_report_filter module. In less than an hour, you can modify the pattern to fit any categorization axis that your client's accounting team requires, whether it cost center, project, product line, geographic region, or any other dimension not covered by the standard Odoo filter set.

To read more about How to Add Filters to Accounting Reports in Odoo 18, refer to our blog How to Add Filters to Accounting Reports in Odoo 18.


Frequently Asked Questions

What is the purpose of the _init_options_invoice_tag method’s naming convention?

Odoo 19’s account.report._init_options() dynamically discovers methods following the pattern _init_options_ and calls them automatically, allowing extension without overriding the parent method.

How does the _get_options_domain override safely merge the custom tag filter with existing report filters?

It uses the Domain class and the & operator to combine the tag domain with domains from journals, dates, partners, fiscal positions, etc., preserving all existing logic.

What is the role of AccountReport.registerCustomComponent() in the OWL frontend?

It is the official extension point provided by account_reports to mount a custom filter component alongside the built-in filter bar, without patching the parent component directly.

Why is the no_create_edit option set on the invoice_tag_id field in the invoice form view?

To prevent users from creating or editing tags inline from the invoice form, keeping tag management controlled through the dedicated configuration menu and avoiding accidental tag proliferation.

What is the significance of using t-inherit-mode="extension" in the XML template?

It patches the existing filter bar template by adding content at specific xpath positions instead of replacing the entire template, preserving all other built-in filters.

If you need any assistance in odoo, we are online, please chat with us.



0
Comments



Leave a comment



whatsapp_icon
location

Calicut

Cybrosys Technologies Pvt. Ltd.
Neospace, Kinfra Techno Park
Kakkancherry, Calicut
Kerala, India - 673635

location

Kochi

Cybrosys Technologies Pvt. Ltd.
1st Floor, Thapasya Building,
Infopark, Kakkanad,
Kochi, India - 682030.

location

Bangalore

Cybrosys Techno Solutions
The Estate, 8th Floor,
Dickenson Road,
Bangalore, India - 560042

Send Us A Message