Skip to main content
BlogERP

Odoo ERP Custom Module Development: A Developer's Guide for Indonesian Business Workflows

Learn how to build production-ready Odoo custom modules from scratch, tailored for Indonesian business requirements like NPWP validation and multi-level purchase approval chains for PT companies. This guide covers ORM basics, model inheritance, XML views, and secure module packaging.

Matthews Wong

Software Engineer

February 202611 min read
Odoo ERP Custom Module Development: A Developer's Guide for Indonesian Business Workflows

Photo by Unsplash

Key Takeaway

Odoo is highly extensible, but fitting it to Indonesian requirements means adding NPWP tax IDs, NIK citizen numbers and company-specific approval hierarchies through the framework's ORM and inheritance model. Extending the existing models rather than replacing them is what keeps a custom module upgradeable across Odoo versions.

Odoo's modular architecture makes it one of the most extensible ERP platforms available, but adapting it to Indonesian regulatory requirements — NPWP tax IDs, NIK citizen numbers, and company-specific approval hierarchies — requires a solid understanding of the framework's ORM and inheritance system. This guide walks through building a complete custom module for a PT (Perseroan Terbatas) company's purchase approval workflow from directory scaffold to production deployment.

Understanding the Odoo Module Structure

Every Odoo module is a Python package with a mandatory __manifest__.py file that declares the module's metadata, dependencies, and data files. A well-organized module separates concerns across models/, views/, security/, controllers/, and static/ directories. Keeping this structure consistent makes your module maintainable by other developers and compatible with Odoo's upgrade mechanism.

The __manifest__.py File

The manifest declares your module's name, version, author, license, and critically its depends list — which other Odoo apps must be installed for your module to function. Always pin the minimum Odoo version with 'version': '17.0.1.0.0' format. Including 'auto_install': False ensures the module only activates when manually installed, preventing unexpected behavior in production.

Security: Access Control Lists and Record Rules

The security/ir.model.access.csv file defines which user groups can create, read, write, or delete records in your custom models. Record rules (ir.rule) provide row-level security, for example restricting a purchase request to only the creator's branch office. Always define security first — deploying a module without proper ACLs exposes all data to all internal users.

Use 'odoo scaffold module_name .' to generate the full directory skeleton automatically. This creates __manifest__.py, __init__.py, models/__init__.py, views/, and security/ with the correct imports, saving 10–15 minutes of boilerplate setup per module.

Building the Data Model with Odoo ORM

Odoo's ORM maps Python classes inheriting from models.Model directly to PostgreSQL tables, handling migrations automatically when you bump the module version. Fields are declared as class attributes using field types like fields.Char, fields.Many2one, and fields.One2many. Computed fields using @api.depends recalculate automatically when their dependencies change, keeping your UI reactive without manual triggers.

Indonesian-Specific Fields: NPWP and NIK Validation

Indonesian tax administration identifies taxpayers by a 16-digit NPWP: resident individuals use their 16-digit NIK, other taxpayers have a 16-digit NPWP, and each place of business gets a 22-digit NITKU, the 16-digit NPWP plus a 6-digit sequence. PER-11/PJ/2025 requires the buyer's NPWP on a tax invoice, or the NIK for a resident individual, and the old 15-digit XX.XXX.XXX.X-XXX.XXX format could be used by other parties only until 31 December 2024. Using @api.constrains to validate these formats at the ORM level ensures invalid data never reaches the database, regardless of whether a record is created via the web UI, XML-RPC, or a data import. Similarly, NIK (Nomor Induk Kependudukan) must be exactly 16 digits — a constraint worth enforcing for audit compliance.

# models/purchase_approval.py
from odoo import models, fields, api
from odoo.exceptions import ValidationError
import re

class PurchaseApproval(models.Model):
    _name = 'custom.purchase.approval'
    _description = 'Custom Purchase Approval for PT Companies'
    _inherit = ['mail.thread', 'mail.activity.mixin']

    name = fields.Char(string='Reference', required=True, copy=False,
                       readonly=True, default='New')
    vendor_id = fields.Many2one('res.partner', string='Vendor', required=True)
    npwp = fields.Char(string='NPWP Vendor', size=16,
                       help='NPWP, 16 digits (resident individuals use their NIK)')
    nitku = fields.Char(string='NITKU Vendor', size=22,
                        help='Place-of-business ID: 16-digit NPWP + 6-digit sequence')
    nik_pic = fields.Char(string='NIK PIC', size=16,
                          help='Nomor Induk Kependudukan of responsible person')
    amount_total = fields.Float(string='Total Amount (IDR)', required=True)
    approval_level = fields.Selection([
        ('manager', 'Manager (< 50jt)'),
        ('director', 'Director (50jt – 500jt)'),
        ('board', 'Board of Directors (> 500jt)'),
    ], string='Required Approval Level', compute='_compute_approval_level',
       store=True)
    state = fields.Selection([
        ('draft', 'Draft'),
        ('submitted', 'Submitted'),
        ('approved', 'Approved'),
        ('rejected', 'Rejected'),
    ], default='draft', tracking=True)

    @api.depends('amount_total')
    def _compute_approval_level(self):
        for rec in self:
            if rec.amount_total < 50_000_000:
                rec.approval_level = 'manager'
            elif rec.amount_total < 500_000_000:
                rec.approval_level = 'director'
            else:
                rec.approval_level = 'board'

    @api.constrains('npwp', 'nitku')
    def _check_tax_ids(self):
        # Since 1 Jul 2024 (PENG-26/PJ.09/2024): 16-digit NPWP or NIK, plus a
        # 22-digit NITKU per place of business. The 15-digit
        # XX.XXX.XXX.X-XXX.XXX form was allowed only until 31 Dec 2024.
        for rec in self:
            if rec.npwp and not re.fullmatch(r'\d{16}', rec.npwp):
                raise ValidationError(
                    'NPWP harus 16 digit angka (NIK untuk orang pribadi penduduk).'
                )
            if rec.nitku and not re.fullmatch(r'\d{22}', rec.nitku):
                raise ValidationError('NITKU harus 22 digit angka.')
            if rec.npwp and rec.nitku and not rec.nitku.startswith(rec.npwp):
                raise ValidationError('16 digit pertama NITKU harus sama dengan NPWP.')

    @api.model
    def create(self, vals):
        if vals.get('name', 'New') == 'New':
            vals['name'] = self.env['ir.sequence'].next_by_code(
                'custom.purchase.approval') or 'New'
        return super().create(vals)

Dynamic Approval Levels with Computed Fields

Indonesian PT companies typically require tiered purchase approvals: manager for amounts below Rp 50 million, director up to Rp 500 million, and board of directors above. Encoding this logic in a stored computed field (store=True) means the approval level is persisted to the database, enabling fast filtering and reporting without recomputing on every view load. The @api.depends decorator ensures the field recalculates whenever amount_total changes.

Defining XML Views and Menus

Views in Odoo are XML records stored in the ir.ui.view model. A form view defines the layout of individual records, a tree view shows the list, and a search view adds filter options to the list. All three are typically required for a new model to be fully usable. Reference field widgets like many2one, statusbar, and stat_button significantly improve UX with minimal extra code.

Extending Existing Views with xpath

When inheriting from an existing model like purchase.order, you can inject new fields into the existing form view using an inherit_id pointing to the original view and xpath expressions to precisely target insertion points. Use position='after', position='before', or position='inside' to control placement. Avoid replacing entire view sections — target the smallest possible node to minimize upgrade conflicts.

Never Modify Core Odoo Modules Directly

Editing files inside the Odoo source tree (addons/account/, addons/purchase/, etc.) will be overwritten on every upgrade, destroying your changes and potentially breaking the entire installation. Always use _inherit to extend existing models and inherit_id in XML to extend existing views. This is non-negotiable for any production deployment that will ever receive security patches.

Controllers and JSON-RPC APIs

Odoo exposes both JSON-RPC and XML-RPC endpoints out of the box, but sometimes you need custom HTTP routes — for mobile apps, webhook receivers, or third-party integrations like e-invoice (e-Faktur) submission to DJP. Controllers inheriting from http.Controller use @http.route() decorators with auth='user' or auth='public' depending on the endpoint's access requirements.

Sequences and Reference Numbers

Indonesian business documents require formatted reference numbers (e.g., PA/2026/05/0001 for purchase approvals). Define an ir.sequence record in your data XML with prefix and padding settings, then call self.env['ir.sequence'].next_by_code() in the model's create() override. Using 'New' as the default and replacing it on save is the Odoo convention that prevents sequence gaps from draft records that are discarded.

Wizard Models for Multi-Step Actions

TransientModel (models.TransientModel) creates wizard forms that do not persist to the database long-term — Odoo auto-purges them after a configurable period. Use wizards for approval confirmation dialogs, bulk operations, or any multi-step action that requires user input before committing. Binding a wizard to an action via binding_model_id makes it appear as a contextual Action button on the relevant list view.

Run 'odoo-bin -d your_db -u your_module --test-enable --stop-after-init' in your CI pipeline to execute unit tests on every push. Odoo's test framework supports TransactionCase for isolated tests and tagged test suites, making it straightforward to automate regression testing for custom module logic.

Testing and Module Quality

A production Odoo module should include at minimum a smoke test that installs the module on a clean database and creates one record through the full workflow. Odoo's test framework supports both unit tests (TransactionCase) and tour tests (HttpCase) for UI automation. Integrating these into a GitLab CI or GitHub Actions pipeline catches regressions before they reach the production instance.

Packaging and Deploying the Module

Package your module as a ZIP file for clients who do not have direct server access, or deploy via Git by adding your module directory to addons_path in odoo.conf. For production environments on Indonesian hosting providers like Biznet or IDCloudHost, use a dedicated odoo user, run the server behind Nginx with TLS, and store custom addons outside the Odoo source tree to survive upgrades cleanly.

Key terms in this article include models.Model, NPWP, _inherit, and computed field.

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

Why should you never modify Odoo's core module files directly?

Editing files inside the Odoo source tree (such as addons/account/ or addons/purchase/) will be overwritten every time you upgrade Odoo, destroying your changes and potentially breaking the entire installation. The correct approach is to use _inherit to extend existing models in Python and inherit_id with xpath in XML to extend existing views. This is non-negotiable for any production deployment that will ever receive security patches.

How does the post recommend handling NPWP and NIK validation in a custom Odoo module?

The post recommends using @api.constrains to enforce the current tax ID formats at the ORM level: a 16-digit NPWP (resident individuals use their 16-digit NIK), a 22-digit NITKU for each place of business, and an NIK of exactly 16 digits. The old 15-digit XX.XXX.XXX.X-XXX.XXX format could be used by other parties only until 31 December 2024. Validating at this layer ensures invalid data never reaches the PostgreSQL database, regardless of whether the record is created through the web UI, XML-RPC, or a bulk data import.

How does Odoo's tiered purchase approval workflow work for Indonesian PT companies?

Indonesian PT companies typically require multi-level purchase approvals: a manager approves amounts below Rp 50 million, a director handles up to Rp 500 million, and the board of directors approves anything above that threshold. The post encodes this logic in a stored computed field (store=True) so the approval level is persisted to the database, enabling fast filtering and reporting without recomputing on every view load. The @api.depends decorator ensures the field recalculates automatically when the purchase amount changes.

What is the role of TransientModel (wizard) in a custom Odoo module?

TransientModel creates wizard forms that do not persist in the database long-term — Odoo automatically purges them after a configurable period. They are suited for approval confirmation dialogs, bulk operations, or any multi-step action that requires user input before committing a change. Binding a wizard to an action via binding_model_id makes it appear as a contextual Action button on the relevant list view.

What is the recommended way to generate formatted reference numbers like PA/2026/05/0001 in Odoo?

The post recommends defining an ir.sequence record in your data XML with the appropriate prefix and padding settings, then calling self.env['ir.sequence'].next_by_code() inside the model's create() override. Using 'New' as the default display value and replacing it on save is the standard Odoo convention — it prevents sequence gaps that would otherwise be caused by draft records that are discarded before saving.