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.
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.

