GuideOdoo AccountingMarch 13, 2026

Odoo 19 Analytic Accounting:
Track Profitability by Project, Department & Cost Center

INTRODUCTION

Your General Ledger Tells You What Happened. Analytic Accounting Tells You Why.

Your chart of accounts answers one question: how much did we spend on salaries, rent, or software licenses? It cannot answer the question that actually drives decisions: which project is profitable, which department is over budget, and which cost center is bleeding cash?

That is the gap analytic accounting fills. It lets you tag every financial transaction—invoices, expenses, timesheets, purchase orders—with one or more analytical dimensions (project, department, cost center, product line, region) so you can slice your P&L by the dimensions that matter to management.

Odoo 19 overhauled analytic accounting with a multi-plan distribution system that replaces the old single-analytic-account model. This means a single invoice line can now be split across multiple projects and multiple departments simultaneously—something that previously required ugly workarounds or third-party modules. This guide walks you through the full setup: how analytic plans work in Odoo 19, how to create and apply distributions, how to build management dashboards, and the automation patterns that eliminate manual tagging.

01

Understanding Analytic Plans and Distributions in Odoo 19

In Odoo 17 and earlier, analytic accounting was built around a single concept: the analytic account. Each journal item could reference one analytic account. If your business needed to track both the project and the department for the same expense, you either created compound accounts (Project A / Marketing) or installed workaround modules. The model broke down at scale.

Odoo 19 introduces analytic plans—independent dimensional axes that can each contain their own tree of analytic accounts. A single transaction can carry distributions across multiple plans simultaneously, each with its own percentage allocation.

The Data Model

ModelPurposeExample
account.analytic.planDefines a dimensional axis (the "plan")Projects, Departments, Cost Centers
account.analytic.accountA node within a plan (the actual account)Project Alpha, Marketing, COGS-EU
account.analytic.distribution.modelDefault distribution rules (auto-fill templates)"All expenses for Product X go 60% Project A / 40% Project B"
account.analytic.lineThe actual analytic entries (journal items)One line per plan allocation per source document line

How Multi-Plan Distribution Works

The key innovation is the analytic_distribution JSON field on account.move.line. Instead of a Many2one to a single analytic account, Odoo 19 stores a dictionary where keys are analytic account IDs and values are percentage allocations:

Python — analytic_distribution field structure
# The analytic_distribution field on account.move.line
# is a JSON dict: { analytic_account_id: percentage }

# Example: 60% to Project Alpha, 40% to Project Beta,
#          100% to Marketing department
{
    "7": 60.0,    # account.analytic.account ID=7 (Project Alpha)
    "8": 40.0,    # account.analytic.account ID=8 (Project Beta)
    "15": 100.0,  # account.analytic.account ID=15 (Marketing)
}
# Percentages are per-plan, not global.
# Projects: 60% + 40% = 100%  ✓
# Departments: 100%            ✓
Key Insight

Percentages must sum to 100% within each plan, not across all plans. You can allocate 100% to a project and 100% to a department on the same line. Odoo validates per-plan totals based on the applicability settings of each plan (optional, mandatory, or unavailable per account type).

02

Creating Analytic Plans for Departments, Projects, and Cost Centers

The UI path is Accounting → Configuration → Analytic Plans. But the real work is designing the plan structure before you touch Odoo. A poorly designed plan hierarchy creates noise instead of signal.

Plan Design Principles

  • One plan per independent dimension. If "project" and "department" can vary independently on the same transaction, they must be separate plans. If they always move together, collapse them into one.
  • Keep plans shallow. Two levels of hierarchy (parent → child) is the sweet spot. Three levels make the distribution widget unwieldy. Four levels mean your plan design is wrong.
  • Use applicability rules. Make the "Projects" plan mandatory on expense accounts and optional on revenue accounts. Make "Departments" mandatory everywhere. This prevents missing data without over-constraining users.
  • Name accounts with codes. Use MKT-001 not Marketing Team Q1 Campaign. The analytic widget is a dropdown—long names get truncated.

Creating Plans via XML Data

For reproducible deployments, define plans in XML data files rather than clicking through the UI:

XML — data/analytic_plans.xml
<odoo>
  <!-- ── Analytic Plans ── -->
  <record id="plan_department" model="account.analytic.plan">
    <field name="name">Department</field>
    <field name="default_applicability">mandatory</field>
    <field name="color">2</field>
  </record>

  <record id="plan_project" model="account.analytic.plan">
    <field name="name">Project</field>
    <field name="default_applicability">optional</field>
    <field name="color">4</field>
  </record>

  <record id="plan_cost_center" model="account.analytic.plan">
    <field name="name">Cost Center</field>
    <field name="default_applicability">optional</field>
    <field name="color">6</field>
  </record>

  <!-- ── Analytic Accounts (Department Plan) ── -->
  <record id="dept_engineering" model="account.analytic.account">
    <field name="name">ENG-001 Engineering</field>
    <field name="plan_id" ref="plan_department"/>
    <field name="code">ENG-001</field>
  </record>

  <record id="dept_marketing" model="account.analytic.account">
    <field name="name">MKT-001 Marketing</field>
    <field name="plan_id" ref="plan_department"/>
    <field name="code">MKT-001</field>
  </record>

  <record id="dept_operations" model="account.analytic.account">
    <field name="name">OPS-001 Operations</field>
    <field name="plan_id" ref="plan_department"/>
    <field name="code">OPS-001</field>
  </record>

  <!-- ── Analytic Accounts (Project Plan) ── -->
  <record id="proj_alpha" model="account.analytic.account">
    <field name="name">PRJ-A Project Alpha</field>
    <field name="plan_id" ref="plan_project"/>
    <field name="code">PRJ-A</field>
  </record>

  <record id="proj_beta" model="account.analytic.account">
    <field name="name">PRJ-B Project Beta</field>
    <field name="plan_id" ref="plan_project"/>
    <field name="code">PRJ-B</field>
  </record>

  <!-- ── Applicability Rules ── -->
  <record id="applicability_project_expense"
          model="account.analytic.applicability">
    <field name="analytic_plan_id" ref="plan_project"/>
    <field name="business_domain">expense</field>
    <field name="applicability">mandatory</field>
  </record>
</odoo>
Applicability Is Your Data Quality Firewall

Set default_applicability to optional on the plan, then override it to mandatory for specific business domains (expense, revenue, purchase) via account.analytic.applicability records. This way, Odoo blocks posting a vendor bill without a project allocation, but doesn't annoy users entering bank fees where project tagging is meaningless.

03

Applying Analytic Distributions to Invoices, Expenses, Timesheets, and Purchase Orders

Creating plans is the foundation. The value comes from tagging transactions consistently. Odoo 19 surfaces the analytic distribution widget on every document that generates journal entries. Here is where and how each source document feeds analytic data.

Customer Invoices and Vendor Bills

The analytic_distribution widget appears on each invoice line in the form view. Users can split a single line across multiple analytic accounts from multiple plans:

Python — Setting analytic distribution programmatically
# Set analytic distribution on an invoice line via code
invoice = self.env['account.move'].browse(invoice_id)
for line in invoice.invoice_line_ids:
    line.analytic_distribution = {
        str(project_alpha.id): 60.0,
        str(project_beta.id): 40.0,
        str(dept_engineering.id): 100.0,
    }

# When the invoice is posted, Odoo creates
# account.analytic.line entries:
# - 60% of line amount → Project Alpha
# - 40% of line amount → Project Beta
# - 100% of line amount → Engineering department

Timesheets

Timesheets in Odoo 19 are account.analytic.line records directly. When an employee logs time against a project task, the analytic account is set automatically from the project's analytic account configuration. The key connection:

Python — Project → Analytic Account link
# project.project has an analytic_account_id field
# When a timesheet is created for this project,
# the analytic line automatically inherits this account.

project = self.env['project.project'].create({
    'name': 'Project Alpha',
    'analytic_account_id': project_alpha_account.id,
    # In Odoo 19, you can also set multi-plan distributions:
    'analytic_distribution': {
        str(project_alpha_account.id): 100.0,
        str(dept_engineering.id): 100.0,
    },
})

# Every timesheet logged against this project
# will carry both the project AND department tags.

Expenses

Employee expenses (hr.expense) carry the same analytic_distribution JSON field. When the expense report is approved and the journal entry is created, the distribution flows through to the accounting entries. Configure default distributions per expense category to reduce manual input:

XML — Default distribution on expense product
<record id="product_travel_expense"
        model="product.product">
  <field name="name">Business Travel</field>
  <field name="can_be_expensed">True</field>
  <field name="analytic_distribution"
         eval="{'7': 100.0, '15': 100.0}"/>
  <!-- 7 = Project Alpha, 15 = Marketing dept -->
</record>

Purchase Orders

Purchase order lines in Odoo 19 carry analytic_distribution. When the vendor bill is created from the PO (either manually or via 3-way matching), the analytic distribution propagates from the PO line to the bill line. This is critical for cost tracking—you tag once at procurement time, and the cost allocation carries through to the general ledger without re-entry.

Propagation Chain

The full propagation path is: Purchase Order Line → Stock Move (if applicable) → Vendor Bill Line → Journal Entry → Analytic Lines. If any link in this chain is missing the distribution, the downstream entries lose the analytic tagging. Always verify the end-to-end flow in your test environment.

04

Building Analytic Reports and Pivot Tables for Management Dashboards

Data without reports is just noise. Odoo 19 provides several native tools for analytic reporting, plus an API that lets you build custom dashboards.

Native Analytic Reports

Navigate to Accounting → Reporting → Analytic to access the built-in analytic reports. The key reports:

ReportUse CaseGrouping
Analytic EntriesRaw list of all analytic lines with filtersPlan, Account, Date, Partner
Analytic PivotCross-tab analysis: rows = projects, cols = monthsAny combination of plan dimensions
Budget vs ActualCompare planned budget against real analytic entriesPer analytic account, per period
Profitability AnalysisRevenue minus costs per analytic accountPer project, department, or cost center

Custom Pivot Table via ORM

For management dashboards that go beyond the native UI, use the read_group API to build server-side aggregations:

Python — Server action: Project profitability summary
from odoo import api, models


class AnalyticProfitabilityReport(models.TransientModel):
    _name = "analytic.profitability.report"
    _description = "Project Profitability Report"

    @api.model
    def get_profitability_data(self, plan_id, date_from, date_to):
        """Return profitability per analytic account in a plan.

        Groups revenue and cost analytic lines, then computes
        margin and margin percentage per account.
        """
        AnalyticLine = self.env['account.analytic.line']

        # Revenue lines (positive amounts from customer invoices)
        revenue_data = AnalyticLine.read_group(
            domain=[
                ('plan_id', '=', plan_id),
                ('date', '>=', date_from),
                ('date', '<=', date_to),
                ('amount', '>', 0),
            ],
            fields=['account_id', 'amount:sum'],
            groupby=['account_id'],
        )

        # Cost lines (negative amounts from bills, expenses, timesheets)
        cost_data = AnalyticLine.read_group(
            domain=[
                ('plan_id', '=', plan_id),
                ('date', '>=', date_from),
                ('date', '<=', date_to),
                ('amount', '<', 0),
            ],
            fields=['account_id', 'amount:sum'],
            groupby=['account_id'],
        )

        # Merge into profitability dict
        revenue_map = {
            r['account_id'][0]: r['amount']
            for r in revenue_data
        }
        cost_map = {
            c['account_id'][0]: abs(c['amount'])
            for c in cost_data
        }

        all_accounts = set(revenue_map) | set(cost_map)
        results = []
        for acc_id in all_accounts:
            rev = revenue_map.get(acc_id, 0.0)
            cost = cost_map.get(acc_id, 0.0)
            margin = rev - cost
            margin_pct = (margin / rev * 100) if rev else 0.0
            account = self.env['account.analytic.account'].browse(acc_id)
            results.append({
                'account': account.display_name,
                'code': account.code,
                'revenue': rev,
                'cost': cost,
                'margin': margin,
                'margin_pct': round(margin_pct, 1),
            })

        return sorted(results, key=lambda r: r['margin'], reverse=True)

Spreadsheet Integration

Odoo 19 Enterprise includes a built-in spreadsheet module that can pull live data from analytic accounts via Odoo Pivot formulas. This lets finance teams build their own dashboards without developer involvement:

Spreadsheet Formula — Odoo Pivot in built-in spreadsheet
=ODOO.PIVOT(1, "amount", "account_id", "Project Alpha", "date:month", "03/2026")

# This pulls the sum of analytic amounts for:
# - Pivot ID 1 (configured as analytic lines pivot)
# - Account = "Project Alpha"
# - Month = March 2026

# Combine with standard spreadsheet formulas:
=ODOO.PIVOT(1,"amount","account_id","PRJ-A","category","revenue")
 - ODOO.PIVOT(1,"amount","account_id","PRJ-A","category","cost")
# Result: Net margin for Project Alpha
Dashboard Tip

Create a dedicated Analytic Dashboard menu item using ir.actions.act_window with view_mode=pivot,graph and a domain pre-filtered to your plan. Pin it to the Accounting module menu. Managers get one-click access to their profitability view without navigating through report menus.

05

Automating Analytic Distributions with Default Rules and Python Compute Methods

Manual analytic tagging is the #1 reason analytic data quality degrades over time. Users forget, users rush, users don't understand which project code to use. The solution: automate distribution assignment so the system fills in the right tags before users even see the field.

Distribution Models (No-Code Defaults)

Odoo 19 provides account.analytic.distribution.model—a rules engine that auto-fills the analytic distribution widget based on conditions. Navigate to Accounting → Configuration → Analytic Distribution Models:

XML — data/analytic_distribution_models.xml
<odoo>
  <!-- Rule: All invoices for Partner "Acme Corp"
       default to Project Alpha (60%) + Project Beta (40%) -->
  <record id="dist_model_acme"
          model="account.analytic.distribution.model">
    <field name="partner_id" ref="base.res_partner_acme"/>
    <field name="analytic_distribution"
           eval="{'7': 60.0, '8': 40.0}"/>
  </record>

  <!-- Rule: All lines using account 600000 (Purchases)
       default to Operations department -->
  <record id="dist_model_purchases"
          model="account.analytic.distribution.model">
    <field name="account_prefix">6000</field>
    <field name="analytic_distribution"
           eval="{'20': 100.0}"/>
    <!-- 20 = OPS-001 Operations -->
  </record>

  <!-- Rule: Product category "IT Services"
       defaults to Engineering department -->
  <record id="dist_model_it_services"
          model="account.analytic.distribution.model">
    <field name="product_categ_id"
           ref="product.product_category_services"/>
    <field name="analytic_distribution"
           eval="{'10': 100.0}"/>
    <!-- 10 = ENG-001 Engineering -->
  </record>
</odoo>

Distribution models match on partner, product, product category, account prefix, and company. When a user creates an invoice line that matches a rule, the analytic distribution widget pre-fills automatically. The user can still override—but the default is correct 90% of the time.

Python Compute Methods for Complex Logic

When the no-code rules engine isn't enough—for example, you need to split costs based on headcount ratios that change monthly—extend the model with a computed field:

Python — models/account_move_line.py
import json
from odoo import api, fields, models


class AccountMoveLine(models.Model):
    _inherit = "account.move.line"

    @api.onchange('product_id', 'partner_id', 'account_id')
    def _onchange_analytic_distribution_custom(self):
        """Auto-assign analytic distribution based on
        custom business rules that go beyond the standard
        distribution model capabilities.
        """
        if not self.product_id or not self.move_id.partner_id:
            return

        # Example: If the partner has a default project
        # on their contact record (custom field)
        partner = self.move_id.partner_id
        if partner.x_default_project_id:
            project_account = partner.x_default_project_id \
                .analytic_account_id
            if project_account:
                dist = dict(self.analytic_distribution or {})
                dist[str(project_account.id)] = 100.0
                self.analytic_distribution = dist

    @api.model
    def _get_headcount_split(self, department_ids):
        """Compute cost split ratios based on current
        headcount per department. Used for shared cost
        allocation (rent, utilities, IT infrastructure).
        """
        Employee = self.env['hr.employee']
        total = 0
        counts = {}
        for dept_id in department_ids:
            dept = self.env['hr.department'].browse(dept_id)
            count = Employee.search_count([
                ('department_id', '=', dept_id),
                ('active', '=', True),
            ])
            counts[dept_id] = count
            total += count

        if not total:
            return {}

        # Map department → analytic account → percentage
        distribution = {}
        for dept_id, count in counts.items():
            dept = self.env['hr.department'].browse(dept_id)
            if dept.x_analytic_account_id:
                pct = round(count / total * 100, 2)
                distribution[str(dept.x_analytic_account_id.id)] = pct

        return distribution
Cron Job Pattern

For recurring shared costs (monthly rent, annual insurance), create a ir.cron job that generates journal entries with headcount-based analytic distributions on the first of each month. This eliminates the "someone forgot to post the rent allocation" problem that plagues manual analytic workflows.

06

3 Analytic Accounting Mistakes That Corrupt Your Profitability Data

1

Migrating from Old Analytic Accounts to New Multi-Plan System

If you're upgrading from Odoo 16 or earlier, your existing account.analytic.account records were created without a plan_id. The Odoo upgrade scripts assign them to a default plan called "Default." But if you had a flat list of 200 analytic accounts mixing projects, departments, and cost centers, they all end up in the same plan. The multi-plan distribution feature is useless until you re-classify them.

Our Fix

Before upgrading: export your analytic accounts to CSV and classify each one into the target plan (project, department, or cost center). After upgrading: run a migration script that moves accounts to the correct plan and updates all historical analytic_distribution JSON fields on posted journal entries. Do not skip the historical data migration—your year-over-year comparisons will be broken if Q1 data lives in the "Default" plan while Q2 data is properly classified.

2

Missing Analytic Distributions on Stock Moves and Landed Costs

You configure analytic distributions on purchase order lines. The vendor bill picks them up correctly. But then you check the COGS journal entries generated by stock moves (delivery orders) and find no analytic data. The cost of goods sold is posted to the GL without any project or department tagging. Your profitability report shows revenue by project but costs as one unallocated lump.

Our Fix

Stock valuation journal entries (stock.valuation.layer) do not carry analytic distributions by default in Odoo 19. You need to extend stock.move to propagate the analytic distribution from the originating sale order line or purchase order line to the valuation journal entry. For landed costs, the same applies—override stock.landed.cost to split the landed cost journal entry across the analytic accounts of the affected product receipts.

3

Intercompany Analytic Entries Creating Phantom Profitability

In multi-company setups, intercompany invoices generate analytic entries in both companies. Company A invoices Company B for shared services. Both the revenue (in A) and the expense (in B) get analytic tagging. When you run a consolidated profitability report, the intercompany revenue and the intercompany expense don't cancel out unless you explicitly exclude them. Your consolidated project margins look inflated because internal revenue is counted as real revenue.

Our Fix

Create a dedicated analytic account tagged INTERCO in each plan. Use analytic distribution models to auto-tag all intercompany invoices with this account. In your reporting queries, add a filter to exclude INTERCO analytic accounts from consolidated views. Alternatively, create a separate "elimination" analytic plan that only contains intercompany accounts, and filter that entire plan out of management reports.

BUSINESS ROI

What Proper Analytic Accounting Delivers to Your Bottom Line

Analytic accounting is not an accounting exercise. It is a management tool that turns your ERP into a decision engine:

15-25%Faster Budget Decisions

When managers can see real-time profitability by project and department, budget reallocation decisions happen in days, not quarters. You stop funding underperforming projects three months too late.

3-5xReporting Speed

Finance teams that manually compile project profitability from spreadsheets spend 3-5 days per month on the task. Automated analytic reports deliver the same data in real time with zero manual effort.

100%Cost Visibility

Every dollar spent is traceable to a project, department, and cost center. No more "unallocated overhead" black holes that hide waste. Full traceability from PO to COGS to the management P&L.

For a services company with 50 active projects and $10M in annual revenue, identifying just one project with negative margins early enough to course-correct can save $200K-$500K annually. The analytic setup described in this guide is a one-time configuration that pays for itself in the first month of operation.

SEO NOTES

Optimization Metadata

Meta Desc

Complete guide to Odoo 19 analytic accounting. Set up multi-plan distributions, track profitability by project, department, and cost center, and automate analytic tagging with default rules.

H2 Keywords

1. "Understanding Analytic Plans and Distributions in Odoo 19"
2. "Creating Analytic Plans for Departments, Projects, and Cost Centers"
3. "Applying Analytic Distributions to Invoices, Expenses, Timesheets, and Purchase Orders"
4. "Building Analytic Reports and Pivot Tables for Management Dashboards"
5. "3 Analytic Accounting Mistakes That Corrupt Your Profitability Data"

Stop Guessing Which Projects Make Money

Your general ledger tells you the company made a profit. Analytic accounting tells you which projects, departments, and cost centers generated that profit—and which ones are quietly eroding it. Without this visibility, you're making resource allocation decisions based on gut feel instead of data.

If your Odoo instance is still running with a flat list of analytic accounts or no analytic setup at all, that's the gap we close. We design the plan structure, configure distribution models, build the management dashboards, and migrate your historical data so you have continuity from day one. The typical setup takes 3-5 days, and the first profitability report it produces usually identifies enough waste to justify the project ten times over.

Book a Free Profitability Audit