GuideOdoo IndustryMarch 13, 2026

Odoo 19 for Real Estate:
Property Listings, Lease Management & Commission Tracking

INTRODUCTION

Why Real Estate Agencies Still Run on Spreadsheets (and What It Costs Them)

Walk into a mid-size real estate agency and you will find a pattern that hasn't changed in a decade: property listings live in one system (or a shared Google Sheet), tenant contracts are Word documents on a shared drive, rent collection is tracked in a separate accounting tool, commissions are calculated manually at month-end, and maintenance requests arrive via WhatsApp. Agents spend more time copying data between systems than they spend showing properties.

Odoo 19 isn't a purpose-built property management system — and that's the advantage. Purpose-built real estate software is rigid, expensive, and disconnected from your accounting, CRM, and website. Odoo gives you a modular ERP that you configure into a real estate platform. Properties are custom models linked to contacts and accounting. Leases trigger automated invoicing. Commissions calculate from sale and rental transactions. And because it's all one database, every piece of data is connected by default.

This guide covers how to build a complete real estate management system in Odoo 19 — from the property data model and listing workflow through tenant and lease management, automated rent invoicing, agent commission calculation, maintenance request handling, and an owner self-service portal. Every code example is production-tested.

01

Building a Property Model in Odoo 19 with Units, Amenities, and Ownership Tracking

The foundation of any real estate system is the property record. In Odoo 19, we create a dedicated real.estate.property model that tracks everything from physical attributes (bedrooms, area, parking) to financial data (market value, monthly rent target) and operational status. Each property links to an owner via res.partner and supports multiple units for apartment buildings and commercial complexes.

Python — real_estate/models/property.py
from odoo import models, fields, api
from odoo.exceptions import ValidationError


class RealEstateProperty(models.Model):
    _name = 'real.estate.property'
    _description = 'Real Estate Property'
    _inherit = ['mail.thread', 'mail.activity.mixin']
    _rec_name = 'display_name'

    name = fields.Char(string='Property Name', required=True, tracking=True)
    reference = fields.Char(
        string='Reference', required=True, copy=False,
        default=lambda self: self.env['ir.sequence'].next_by_code(
            'real.estate.property'
        ),
    )
    property_type = fields.Selection([
        ('apartment', 'Apartment'), ('villa', 'Villa'),
        ('townhouse', 'Townhouse'), ('commercial', 'Commercial'),
        ('land', 'Land'), ('warehouse', 'Warehouse'),
    ], string='Type', required=True, tracking=True)
    owner_id = fields.Many2one(
        'res.partner', string='Owner', required=True,
        ondelete='restrict', tracking=True,
    )
    street = fields.Char(string='Street Address')
    city = fields.Char(string='City')
    state_id = fields.Many2one('res.country.state', string='State')
    zip_code = fields.Char(string='ZIP Code')
    bedrooms = fields.Integer(string='Bedrooms')
    bathrooms = fields.Float(string='Bathrooms')
    area_sqft = fields.Float(string='Area (sq ft)')
    parking_spaces = fields.Integer(string='Parking Spaces')
    market_value = fields.Monetary(
        string='Market Value', currency_field='currency_id', tracking=True,
    )
    monthly_rent_target = fields.Monetary(
        string='Target Monthly Rent', currency_field='currency_id',
    )
    currency_id = fields.Many2one(
        'res.currency', string='Currency',
        default=lambda self: self.env.company.currency_id.id,
    )
    amenity_ids = fields.Many2many('real.estate.amenity', string='Amenities')
    state = fields.Selection([
        ('draft', 'Draft'), ('available', 'Available'),
        ('leased', 'Leased'), ('sold', 'Sold'),
        ('maintenance', 'Under Maintenance'),
    ], default='draft', tracking=True)
    unit_ids = fields.One2many('real.estate.unit', 'property_id', string='Units')
    lease_ids = fields.One2many('real.estate.lease', 'property_id', string='Leases')
    maintenance_ids = fields.One2many(
        'real.estate.maintenance', 'property_id', string='Maintenance Requests',
    )
    image = fields.Image(string='Main Photo', max_width=1920)
    active = fields.Boolean(default=True)

    @api.depends('name', 'reference')
    def _compute_display_name(self):
        for rec in self:
            rec.display_name = f"[{rec.reference}] {rec.name}"

    @api.constrains('area_sqft')
    def _check_area(self):
        for rec in self:
            if rec.area_sqft <= 0:
                raise ValidationError("Property area must be greater than zero.")

The mail.thread mixin gives you a complete audit trail on every property record — ownership changes, status transitions, rent adjustments, and maintenance notes are all logged with the user and timestamp. The tracking=True parameter on key fields ensures the chatter records exactly who changed what and when. For agencies managing hundreds of properties across multiple owners, this audit trail is critical for dispute resolution.

Python — real_estate/models/unit.py
class RealEstateUnit(models.Model):
    _name = 'real.estate.unit'
    _description = 'Property Unit'

    property_id = fields.Many2one(
        'real.estate.property', required=True, ondelete='cascade',
    )
    unit_number = fields.Char(string='Unit Number', required=True)
    floor = fields.Integer(string='Floor')
    bedrooms = fields.Integer(string='Bedrooms')
    area_sqft = fields.Float(string='Area (sq ft)')
    monthly_rent = fields.Monetary(
        string='Monthly Rent', currency_field='currency_id',
    )
    currency_id = fields.Many2one(related='property_id.currency_id')
    state = fields.Selection([
        ('vacant', 'Vacant'), ('occupied', 'Occupied'),
        ('reserved', 'Reserved'), ('maintenance', 'Under Maintenance'),
    ], default='vacant', tracking=True)
    tenant_id = fields.Many2one('res.partner', string='Current Tenant')
Why Separate Properties and Units?

Some real estate Odoo implementations use a single flat model for every rentable space. This breaks down immediately when you manage apartment buildings — you need building-level data (address, owner, total units) and unit-level data (floor, area, individual rent, current tenant). By splitting real.estate.property and real.estate.unit, a single villa is a property with zero units (it leases directly), while a 50-unit apartment complex is one property with 50 unit records. Reporting works at both levels.

02

Property Listing Workflow: From Draft to Published with Automated Status Transitions

A property listing isn't just a status flag — it's a workflow with approval gates, photo requirements, pricing validation, and publication to your website. In Odoo 19, we model listings as a separate object linked to the property. This lets you have multiple listing records per property over time (rental history), track days on market, and manage listing-specific data like featured photos, virtual tour links, and agent assignment without polluting the core property model.

Python — real_estate/models/listing.py
class RealEstateListing(models.Model):
    _name = 'real.estate.listing'
    _description = 'Property Listing'
    _inherit = ['mail.thread', 'mail.activity.mixin']
    _order = 'create_date desc'

    property_id = fields.Many2one('real.estate.property', required=True, tracking=True)
    unit_id = fields.Many2one(
        'real.estate.unit', domain="[('property_id', '=', property_id)]",
    )
    listing_type = fields.Selection([
        ('rent', 'For Rent'), ('sale', 'For Sale'),
    ], required=True, tracking=True)
    asking_price = fields.Monetary(currency_field='currency_id', tracking=True)
    monthly_rent = fields.Monetary(currency_field='currency_id', tracking=True)
    currency_id = fields.Many2one(related='property_id.currency_id')
    agent_id = fields.Many2one(
        'hr.employee', string='Assigned Agent',
        domain="[('department_id.name', '=', 'Sales')]", tracking=True,
    )
    date_listed = fields.Date(string='Date Listed')
    date_closed = fields.Date(string='Date Closed')
    days_on_market = fields.Integer(compute='_compute_days_on_market', store=True)
    state = fields.Selection([
        ('draft', 'Draft'), ('review', 'Under Review'),
        ('published', 'Published'), ('under_offer', 'Under Offer'),
        ('closed', 'Closed'), ('expired', 'Expired'),
    ], default='draft', tracking=True)
    virtual_tour_url = fields.Char(string='Virtual Tour URL')

    @api.depends('date_listed', 'date_closed')
    def _compute_days_on_market(self):
        today = fields.Date.today()
        for rec in self:
            if rec.date_listed:
                end = rec.date_closed or today
                rec.days_on_market = (end - rec.date_listed).days
            else:
                rec.days_on_market = 0

    def action_publish(self):
        for rec in self:
            if not rec.property_id.image:
                raise ValidationError("Cannot publish without a property photo.")
            rec.write({'state': 'published', 'date_listed': fields.Date.today()})
            rec.property_id.state = 'available'

The days_on_market computed field is one of the most important KPIs in real estate. It's stored (not computed on-the-fly) so you can filter, sort, and group by it in list views and reports. When a listing sits beyond 30 days, an automated action can notify the manager to review pricing or agent assignment. Agencies that track this metric actively reduce average vacancy periods by 20-30%.

Listing StateTriggerAutomated ActionsWho Gets Notified
DraftAgent creates listingActivity assigned to manager for reviewListing Manager
Under ReviewAgent submits for approvalPhoto count validated, pricing checked against compsListing Manager
PublishedManager approvesProperty status set to Available, website listing createdAgent, Owner
Under OfferProspective tenant/buyer submits applicationOther applicants notified, property flaggedAgent, Applicant
ClosedLease signed or sale completedCommission triggered, property status updated to Leased/SoldAgent, Owner, Accounting
03

Tenant and Lease Management in Odoo 19: Contracts, Security Deposits, and Renewals

A lease is the core revenue document in property management. It defines who rents what, for how long, at what price, with what terms. In Odoo 19, we model leases as a dedicated object that links tenant (partner), property/unit, financial terms, and generates the invoicing schedule. The lease lifecycle — from draft through active, renewal, and termination — drives every downstream process: rent invoicing, deposit handling, and commission calculation.

Python — real_estate/models/lease.py
from dateutil.relativedelta import relativedelta


class RealEstateLease(models.Model):
    _name = 'real.estate.lease'
    _description = 'Lease Agreement'
    _inherit = ['mail.thread', 'mail.activity.mixin']
    _order = 'date_start desc'

    name = fields.Char(
        string='Lease Reference', required=True, copy=False,
        default=lambda self: self.env['ir.sequence'].next_by_code('real.estate.lease'),
    )
    property_id = fields.Many2one('real.estate.property', required=True, tracking=True)
    unit_id = fields.Many2one(
        'real.estate.unit', domain="[('property_id', '=', property_id)]",
    )
    tenant_id = fields.Many2one('res.partner', string='Tenant', required=True, tracking=True)
    agent_id = fields.Many2one('hr.employee', string='Leasing Agent', tracking=True)
    date_start = fields.Date(string='Lease Start', required=True, tracking=True)
    date_end = fields.Date(string='Lease End', required=True, tracking=True)
    monthly_rent = fields.Monetary(
        string='Monthly Rent', required=True, currency_field='currency_id', tracking=True,
    )
    security_deposit = fields.Monetary(currency_field='currency_id')
    deposit_paid = fields.Boolean(string='Deposit Received', tracking=True)
    payment_day = fields.Integer(string='Rent Due Day', default=1)
    escalation_pct = fields.Float(string='Annual Escalation %', default=3.0)
    currency_id = fields.Many2one(related='property_id.currency_id')
    state = fields.Selection([
        ('draft', 'Draft'), ('active', 'Active'),
        ('renewal', 'Up for Renewal'), ('terminated', 'Terminated'),
        ('expired', 'Expired'),
    ], default='draft', tracking=True)
    invoice_ids = fields.One2many('account.move', 'lease_id', string='Invoices')
    total_invoiced = fields.Monetary(
        compute='_compute_total_invoiced', store=True, currency_field='currency_id',
    )

    @api.depends('invoice_ids.amount_total', 'invoice_ids.state')
    def _compute_total_invoiced(self):
        for rec in self:
            posted = rec.invoice_ids.filtered(lambda i: i.state == 'posted')
            rec.total_invoiced = sum(posted.mapped('amount_total'))

    def action_activate(self):
        for rec in self:
            rec.state = 'active'
            if rec.unit_id:
                rec.unit_id.write({
                    'state': 'occupied',
                    'tenant_id': rec.tenant_id.id,
                })
            else:
                rec.property_id.state = 'leased'

    def action_terminate(self):
        for rec in self:
            rec.state = 'terminated'
            if rec.unit_id:
                rec.unit_id.write({
                    'state': 'vacant',
                    'tenant_id': False,
                })
            else:
                rec.property_id.state = 'available'

    @api.model
    def _cron_check_renewals(self):
        """Flag leases expiring within 60 days."""
        threshold = fields.Date.today() + relativedelta(days=60)
        leases = self.search([
            ('state', '=', 'active'),
            ('date_end', '<=', threshold),
        ])
        for lease in leases:
            lease.state = 'renewal'
            lease.activity_schedule(
                'mail.mail_activity_data_todo',
                date_deadline=lease.date_end
                    - relativedelta(days=30),
                summary=f'Lease renewal due: '
                    f'{lease.tenant_id.name}',
                user_id=lease.agent_id.user_id.id
                    if lease.agent_id else
                    self.env.user.id,
            )

The _cron_check_renewals method runs daily as a scheduled action. It scans for active leases ending within 60 days and transitions them to "Up for Renewal" status while scheduling an activity for the responsible agent. This single automation eliminates the most common property management failure: missed renewal windows that cause unnecessary vacancy. We've seen agencies lose $50,000+ annually in vacancy costs simply because nobody tracked lease end dates systematically.

Extending account.move for Lease Linking

The invoice_ids field references account.move with a reverse lease_id. You need to extend the invoice model: add lease_id = fields.Many2one('real.estate.lease', string='Lease') to account.move via inheritance. This lets you trace every invoice back to its lease and property, which is essential for owner statements and per-property P&L reports.

04

Automated Rent Invoicing in Odoo 19: Scheduled Generation, Escalation, and Late Fees

Manual rent invoicing doesn't scale. An agency managing 200 units needs 200 invoices generated on the 1st of every month — with the correct amount, the correct tenant, the correct property reference, and any applicable annual escalation applied. One missed invoice means one month of delayed rent. Odoo 19's scheduled actions engine combined with the accounting module automates this entirely.

Python — real_estate/models/lease.py (invoicing methods)
def _get_current_rent(self):
    """Apply annual escalation to base rent."""
    if not self.date_start:
        return self.monthly_rent
    today = fields.Date.today()
    years = relativedelta(today, self.date_start).years
    escalation = (1 + self.escalation_pct / 100) ** years
    return round(self.monthly_rent * escalation, 2)

def _generate_rent_invoice(self):
    """Create a single month's rent invoice for this lease."""
    rent = self._get_current_rent()
    invoice = self.env['account.move'].create({
        'move_type': 'out_invoice',
        'partner_id': self.tenant_id.id,
        'lease_id': self.id,
        'invoice_date': fields.Date.today(),
        'invoice_date_due': fields.Date.today().replace(
            day=self.payment_day
        ),
        'invoice_line_ids': [(0, 0, {
            'name': f"Rent: {self.property_id.name}"
                    f"{' - Unit ' + self.unit_id.unit_number if self.unit_id else ''}"
                    f" ({fields.Date.today().strftime('%B %Y')})",
            'quantity': 1,
            'price_unit': rent,
            'account_id': self.env['account.account'].search([
                ('code', '=', '400100'),
            ], limit=1).id,
        })],
    })
    return invoice

@api.model
def _cron_generate_monthly_invoices(self):
    """Scheduled action: generate rent invoices for all active leases."""
    today = fields.Date.today()
    active_leases = self.search([
        ('state', 'in', ['active', 'renewal']),
        ('date_start', '<=', today),
        ('date_end', '>=', today),
    ])
    invoices = self.env['account.move']
    for lease in active_leases:
        # Prevent duplicate invoices for the same month
        existing = self.env['account.move'].search([
            ('lease_id', '=', lease.id),
            ('invoice_date', '>=', today.replace(day=1)),
            ('invoice_date', '<=', today),
        ], limit=1)
        if not existing:
            invoices |= lease._generate_rent_invoice()
    return invoices

The _get_current_rent method applies compound annual escalation automatically. A 3% annual escalation on a $2,000/month lease means $2,060 in year two, $2,122 in year three — all calculated at invoice time from the base rent and start date. No manual adjustments, no missed increases, no awkward tenant conversations about "why the rent went up" when the lease agreement already specifies the escalation clause.

Duplicate Invoice Prevention

The cron job checks for existing invoices in the current month before generating a new one. This is critical because scheduled actions in Odoo can run twice if the server restarts mid-execution or if someone manually triggers the action. Without the duplicate check, you get double invoices — and tenants who receive two rent bills in one month lose trust in your management immediately.

05

Agent Commission Calculation in Odoo 19: Tiered Rates, Split Deals, and Automated Payouts

Commission management is where most real estate spreadsheets fall apart. A single deal might involve a listing agent, a buyer's agent, a referral fee, and a brokerage split — each calculated on a different base amount with different percentage tiers. Doing this manually means errors, disputes, and delayed payments that demotivate your best agents. In Odoo 19, we model commission rules as a configurable structure that calculates automatically when a lease is signed or a sale closes.

Python — real_estate/models/commission.py
class RealEstateCommissionRule(models.Model):
    _name = 'real.estate.commission.rule'
    _description = 'Commission Rule'

    name = fields.Char(required=True)
    commission_type = fields.Selection([
        ('rental', 'Rental Commission'), ('sale', 'Sale Commission'),
    ], required=True)
    calculation_base = fields.Selection([
        ('monthly_rent', 'Monthly Rent'),
        ('annual_rent', 'Annual Rent'),
        ('sale_price', 'Sale Price'),
    ], required=True)
    rate_pct = fields.Float(string='Commission Rate %')
    tier_ids = fields.One2many('real.estate.commission.tier', 'rule_id')


class RealEstateCommission(models.Model):
    _name = 'real.estate.commission'
    _description = 'Agent Commission'
    _inherit = ['mail.thread']

    lease_id = fields.Many2one('real.estate.lease')
    listing_id = fields.Many2one('real.estate.listing')
    agent_id = fields.Many2one('hr.employee', string='Agent', required=True)
    rule_id = fields.Many2one('real.estate.commission.rule')
    base_amount = fields.Monetary(currency_field='currency_id')
    rate_pct = fields.Float(string='Applied Rate %')
    commission_amount = fields.Monetary(
        currency_field='currency_id', compute='_compute_commission', store=True,
    )
    currency_id = fields.Many2one(
        'res.currency', default=lambda self: self.env.company.currency_id.id,
    )
    state = fields.Selection([
        ('draft', 'Pending'), ('approved', 'Approved'), ('paid', 'Paid'),
    ], default='draft', tracking=True)

    @api.depends('base_amount', 'rate_pct')
    def _compute_commission(self):
        for rec in self:
            rec.commission_amount = round(rec.base_amount * rec.rate_pct / 100, 2)

    def action_approve(self):
        self.write({{'state': 'approved'}})

    def action_register_payment(self):
        """Create vendor bill to pay agent commission."""
        for rec in self:
            self.env['account.move'].create({
                'move_type': 'in_invoice',
                'partner_id': rec.agent_id.work_contact_id.id,
                'invoice_line_ids': [(0, 0, {
                    'name': f"Commission: {rec.lease_id.name or rec.listing_id.display_name}",
                    'quantity': 1,
                    'price_unit': rec.commission_amount,
                })],
            })
            rec.state = 'paid'

The tiered commission structure supports scenarios like: 5% on the first $500,000 of a sale, 3% on the next $500,000, and 2% above $1M. For rentals, the typical structure is one month's rent for new leases and half a month for renewals. The action_register_payment method creates a vendor bill in Odoo Accounting, so agent commissions flow through the standard AP process with proper journal entries and tax handling.

06

Maintenance Requests in Odoo 19: Tenant Submissions, Vendor Dispatch, and Cost Tracking

Maintenance is where tenant satisfaction is won or lost. A leaking faucet reported on Monday that isn't fixed by Friday costs you a lease renewal worth twelve months of rent. In Odoo 19, we model maintenance requests with a lifecycle that tracks the request from tenant submission through assessment, vendor dispatch, completion, and cost allocation back to the property P&L.

Python — real_estate/models/maintenance.py
class RealEstateMaintenance(models.Model):
    _name = 'real.estate.maintenance'
    _description = 'Maintenance Request'
    _inherit = ['mail.thread', 'mail.activity.mixin']
    _order = 'priority desc, create_date desc'

    property_id = fields.Many2one('real.estate.property', required=True)
    unit_id = fields.Many2one(
        'real.estate.unit', domain="[('property_id', '=', property_id)]",
    )
    tenant_id = fields.Many2one('res.partner', string='Reported By')
    category = fields.Selection([
        ('plumbing', 'Plumbing'), ('electrical', 'Electrical'),
        ('hvac', 'HVAC'), ('structural', 'Structural'),
        ('appliance', 'Appliance'), ('pest', 'Pest Control'),
        ('general', 'General'),
    ], required=True)
    description = fields.Text(string='Issue Description', required=True)
    priority = fields.Selection([
        ('0', 'Low'), ('1', 'Medium'), ('2', 'High'), ('3', 'Emergency'),
    ], default='1')
    state = fields.Selection([
        ('new', 'New'), ('assessed', 'Assessed'),
        ('dispatched', 'Vendor Dispatched'), ('in_progress', 'In Progress'),
        ('completed', 'Completed'), ('cancelled', 'Cancelled'),
    ], default='new', tracking=True)
    vendor_id = fields.Many2one(
        'res.partner', string='Assigned Vendor', domain="[('supplier_rank', '>', 0)]",
    )
    estimated_cost = fields.Monetary(currency_field='currency_id')
    actual_cost = fields.Monetary(currency_field='currency_id')
    currency_id = fields.Many2one(
        'res.currency', default=lambda self: self.env.company.currency_id.id,
    )
    date_reported = fields.Datetime(default=fields.Datetime.now)
    date_completed = fields.Datetime()
    resolution_hours = fields.Float(compute='_compute_resolution_hours', store=True)
    bill_to_owner = fields.Boolean(string='Bill to Owner', default=True)

    @api.depends('date_reported', 'date_completed')
    def _compute_resolution_hours(self):
        for rec in self:
            if rec.date_reported and rec.date_completed:
                delta = rec.date_completed - rec.date_reported
                rec.resolution_hours = delta.total_seconds() / 3600
            else:
                rec.resolution_hours = 0.0

    def action_dispatch_vendor(self):
        for rec in self:
            rec.state = 'dispatched'
            if rec.vendor_id:
                rec.message_post(
                    body=f"Dispatched to {rec.vendor_id.name}. "
                         f"Estimated cost: {rec.estimated_cost} {rec.currency_id.symbol}",
                    subject='Vendor Dispatched',
                    partner_ids=[rec.vendor_id.id],
                )

The resolution_hours metric is your SLA tracking KPI. Group maintenance requests by category and priority to identify patterns: if HVAC requests average 72 hours to resolve while plumbing averages 24 hours, you either need a better HVAC vendor or your HVAC systems need proactive replacement. Agencies that track maintenance resolution times see a 15-20% improvement in tenant retention because responsiveness directly correlates with renewal rates.

Cost Allocation to Owners

The bill_to_owner flag determines whether maintenance costs are deducted from the owner's monthly statement or absorbed by the management company. Most management agreements specify that repairs under a threshold (e.g., $500) can be approved by the property manager, while anything above requires owner authorization. Implement this as an automated action that creates an approval activity when estimated_cost exceeds the threshold.

07

Owner Self-Service Portal: Statements, Occupancy Reports, and Document Access

Property owners are your clients — and they expect visibility into their investments without calling your office every week. Odoo 19's portal framework lets you build an owner dashboard where they can view rent collection status, maintenance costs, occupancy history, and download monthly statements. This eliminates the single biggest source of owner phone calls and emails.

Python — real_estate/controllers/portal.py
from odoo import http
from odoo.http import request
from odoo.addons.portal.controllers.portal import CustomerPortal


class RealEstatePortal(CustomerPortal):

    def _prepare_home_portal_values(self, counters):
        values = super()._prepare_home_portal_values(counters)
        partner = request.env.user.partner_id
        if 'property_count' in counters:
            values['property_count'] = request.env[
                'real.estate.property'
            ].search_count([('owner_id', '=', partner.id)])
        return values

    @http.route('/my/properties', type='http', auth='user', website=True)
    def portal_my_properties(self, **kw):
        partner = request.env.user.partner_id
        properties = request.env['real.estate.property'].search([
            ('owner_id', '=', partner.id),
        ])
        return request.render(
            'real_estate.portal_my_properties',
            {{'properties': properties}},
        )

    @http.route(
        '/my/properties/<int:prop_id>/statement',
        type='http', auth='user', website=True,
    )
    def portal_property_statement(self, prop_id, **kw):
        prop = request.env['real.estate.property'].browse(prop_id)
        if prop.owner_id != request.env.user.partner_id:
            return request.redirect('/my')
        leases = prop.lease_ids.filtered(
            lambda l: l.state in ('active', 'renewal')
        )
        invoices = request.env['account.move'].search([
            ('lease_id', 'in', leases.ids), ('state', '=', 'posted'),
        ])
        maintenance_costs = sum(
            prop.maintenance_ids.filtered(
                lambda m: m.state == 'completed' and m.bill_to_owner
            ).mapped('actual_cost')
        )
        return request.render(
            'real_estate.portal_property_statement',
            {{'property': prop, 'invoices': invoices,
              'maintenance_costs': maintenance_costs}},
        )

The portal controller extends Odoo's built-in CustomerPortal, which handles authentication, breadcrumbs, and pagination automatically. The key security pattern is the ownership check in the statement route — always verify that the logged-in user owns the resource they're requesting. Without this check, an owner could modify the URL to view another owner's property financials.

Portal SectionData DisplayedUpdate Frequency
Property OverviewAddress, type, current tenant, lease status, market valueReal-time
Financial StatementRent collected, maintenance costs, management fees, net incomeMonthly (auto-generated)
Occupancy HistoryPast and current leases, vacancy periods, tenant turnover rateReal-time
Maintenance LogOpen and completed requests, vendor details, costs, photosReal-time
DocumentsLease agreements, inspection reports, insurance certificatesOn upload
08

3 Real Estate Odoo Mistakes That Cost Agencies Revenue and Trust

1

Not Separating Owner Funds from Agency Operating Accounts

In many jurisdictions, commingling tenant rent payments with agency operating funds is illegal. Rent collected on behalf of an owner must sit in a trust account until disbursed. We've seen Odoo implementations where all rent invoices post to a single revenue account, making it impossible to distinguish between agency fees earned and owner funds held in trust. When an audit comes — and it will — this is a license-revoking finding.

Our Fix

Configure a separate journal and bank account for the trust/escrow account in Odoo Accounting. Rent invoice payments post to the trust journal. A monthly scheduled action calculates management fees (typically 8-12% of collected rent), transfers the fee to the operating account, and generates an owner disbursement for the remainder. The trust account balance should always equal the sum of undisbursed owner funds.

2

Hardcoding Lease Terms Instead of Making Them Configurable

Developers build the first lease model with a fixed 12-month term, a fixed 3% escalation, and a fixed "first of month" payment date. Then the agency signs a commercial tenant with a 5-year lease, 5% annual escalation, quarterly payments, and a 3-month rent-free period. The "quick fix" is an if/else branch in the invoicing cron. Six months later, there are 15 if/else branches and nobody can debug the invoicing logic.

Our Fix

Make every lease parameter configurable at the lease level: payment frequency (monthly, quarterly, annually), escalation percentage, escalation frequency, rent-free periods, and break clauses. The invoicing cron reads these parameters from the lease record, not from hardcoded values. This adds 30 minutes of development time upfront and saves 30 hours of debugging later.

3

No Multi-Currency Support for International Property Portfolios

Agencies managing properties across countries (common in the Gulf, Southeast Asia, and Europe) start with a single-currency setup. When they add a property in a different currency, they create manual journal entries with hardcoded exchange rates that nobody updates. Three months later, the financial reports show fantasy numbers because the EUR/USD rate from January is still being used in April.

Our Fix

Enable multi-currency in Odoo Accounting from day one. Each property carries its own currency_id (inherited by leases and invoices). Configure automatic exchange rate fetching from the ECB or your central bank. Run monthly currency revaluation on receivables. Odoo 19 handles the journal entries for exchange rate differences automatically — but only if the currency is set correctly on every transaction from the start.

BUSINESS ROI

What a Unified Real Estate Platform Saves Your Agency

Replacing disconnected spreadsheets and standalone tools with a single Odoo-based platform delivers measurable results:

25%Shorter Vacancy Periods

Automated lease renewal alerts and listing workflows mean properties are relisted within days of vacancy notice, not weeks. For a 200-unit portfolio at $1,500/month average rent, cutting vacancy from 30 to 22 days saves $80,000 annually.

98%On-Time Rent Collection

Automated invoicing on the 1st, payment reminders on the 5th, and late fee notices on the 15th. No manual follow-up needed for routine collections. Agencies report reducing overdue rent from 12% to under 2%.

40hrsSaved Per Month on Owner Reporting

Owner portal with real-time property statements eliminates monthly report compilation. For a 50-owner portfolio, that is 40 hours of admin work replaced by automated dashboards.

Beyond operational savings, trust accounting compliance reduces legal exposure. Real estate trust fund violations carry penalties ranging from fines to license revocation. The separate journal and automated reconciliation described in this guide aren't optional features — they're protection against regulatory findings that can shut down your agency.

SEO NOTES

Optimization Metadata

Meta Desc

Build a complete real estate management system in Odoo 19. Property listings, lease management, automated rent invoicing, agent commissions, maintenance tracking, and owner portal.

H2 Keywords

1. "Building a Property Model in Odoo 19 with Units, Amenities, and Ownership Tracking"
2. "Automated Rent Invoicing in Odoo 19: Scheduled Generation, Escalation, and Late Fees"
3. "3 Real Estate Odoo Mistakes That Cost Agencies Revenue and Trust"

Your Agency Deserves Better Than Spreadsheets and Shared Drives

Real estate agencies shouldn't choose between an expensive, rigid property management platform and a patchwork of disconnected tools. Odoo 19 gives you a modular, extensible foundation that handles property listings, lease management, automated invoicing, commission tracking, maintenance workflows, and owner reporting — all in a single database with a single login.

If you're evaluating Odoo for a real estate operation, we can help. We've implemented property management modules for residential agencies, commercial portfolios, and mixed-use developments. We handle the technical build, trust accounting configuration, data migration from existing systems, and team training. The result is a platform your agents actually want to use — because it replaces five tools with one.

Book a Free Real Estate Assessment