odoo.
odoo60 min read

Xây dựng module kế toán VAT Việt Nam cho Odoo 19 từ đầu

Hướng dẫn 7 bước xây dựng Odoo addon kế toán VAT Việt Nam: chart of accounts Thông tư 200, thuế GTGT 0/5/8/10%, hóa đơn GTGT, MST validation, Tờ khai Mẫu 01/GTGT, XML export — kèm companion repo step-by-step.

Step 1: Khung addon — manifest, depends, thư mục placeholder

📋 Đây là phiên bản demo mã nguồn mở. Để có bản đầy đủ với những tính năng vượt trội — hỗ trợ đa công ty, tích hợp iHTKK/eTax trực tiếp, báo cáo thuế TNDN, và triển khai production-ready — vui lòng liên hệ MercTechs.

Bước 1 của loạt bài này chỉ làm một việc, nhưng làm thật chắc: dựng lên bộ khung của addon Odoo 16 cho phần localization kế toán VAT Việt Nam. Cụ thể là tạo thư mục module l10n_vn_vat_accounting/, thêm file __manifest__.py với metadata đầy đủ, khai báo depends = ["account"] để kế thừa module kế toán lõi của Odoo, và chuẩn bị sẵn các thư mục models/, views/, security/ cho các bước sau.

Lý do phải tách riêng bước này: nếu manifest sai, Odoo sẽ không nhìn thấy addon — Apps → Update Apps List sẽ bỏ qua hoàn toàn. Mọi công việc về sau — Thông tư 200 chart of accounts, hoá đơn GTGT, Tờ khai Mẫu 01/GTGT — đều không thể nạp được nếu nền móng này chưa vững. Vì vậy step 1 phải chốt bằng test tự động để xác nhận manifest hợp lệ trước khi viết bất kỳ model nghiệp vụ nào.

Setup

Trước khi viết code, cần chuẩn bị một số thứ ở cấp repo.

Cấu trúc thư mục tuân theo đúng quy ước Odoo 16: tên module là slug snake_case (l10n_vn_vat_accounting), nằm ngay dưới codebase/ để sau này có thể symlink hoặc copy vào addons-path của một Odoo instance. Một file pyproject.toml ở gốc codebase/ chỉ định test runner pytest. Ta không dùng uv ở đây vì addon Odoo phải chạy trên Python interpreter của chính Odoo — nhưng pytest hoàn toàn đủ để kiểm tra metadata của manifest ngoài context Odoo runtime.

File test tests/test_manifest.py đọc __manifest__.py bằng ast.literal_eval — cách an toàn để validate manifest là một Python literal dict, không có code execution. Đây đúng với quy ước của Odoo: __manifest__.py luôn phải là một dict literal, không được có import hay logic. Hai thư mục placeholder views/security/ có file .gitkeep bên trong để git track được thư mục rỗng — khi các step sau drop file vào đúng chỗ, diff sẽ gọn gàng hơn nhiều.

Module phụ thuộc duy nhất trong manifest là account — module Accounting lõi của Odoo. Tất cả các tính năng VAT Việt Nam — tax records, GTGT invoice numbering, Tờ khai Mẫu 01/GTGT — đều mở rộng từ các model của account (chủ yếu là account.move, account.tax, account.account). Không khai báo account ở đây sẽ khiến Odoo nạp addon trước khi các model account.* tồn tại, và mọi _inherit ở các step sau sẽ vô nghĩa.

Triển khai

File trung tâm của step 1 là codebase/l10n_vn_vat_accounting/__manifest__.py:

{
    "name": "Vietnam VAT Accounting",
    "summary": (
        "Kế toán VAT Việt Nam: Thông tư 200 chart of accounts, "
        "hoá đơn GTGT, and Tờ khai thuế GTGT (Mẫu 01/GTGT) workflows."
    ),
    "description": (
        "Localization addon that extends Odoo 16 Accounting to cover the "
        "Vietnamese statutory VAT regime defined by Thông tư 200/2014/TT-BTC "
        "and Thông tư 78/2021/TT-BTC. Provides the Vietnamese chart of "
        "accounts, VAT tax records, GTGT invoice numbering, MST partner "
        "validation, and the Mẫu 01/GTGT declaration report."
    ),
    "version": "16.0.1.0.0",
    "category": "Accounting/Localizations",
    "license": "LGPL-3",
    "author": "vytharion",
    "website": "https://github.com/vytharion/odoo-ke-toan-vat-vietnam",
    "depends": [
        "account",
    ],
    "data": [],
    "demo": [],
    "installable": True,
    "application": False,
    "auto_install": False,
}

Một vài điểm cần giải thích rõ hơn:

  • version: "16.0.1.0.0" theo đúng Odoo OCA convention: <odoo_series>.<addon_major>.<addon_minor>.<addon_patch>. Tiền tố 16.0 giúp Odoo apps registry tự loại bỏ addon nếu vô tình cài vào instance v15 hoặc v17.
  • category: "Accounting/Localizations" là category chuẩn mà Odoo dùng để gom các module l10n_xx vào một nhóm trong Apps. Sai category vẫn nạp được, nhưng người dùng sẽ khó tìm hơn nhiều.
  • license: "LGPL-3" tương thích với Odoo Community và yêu cầu redistribute của OCA. Tránh Other proprietary trừ khi có lý do thương mại rõ ràng.
  • application: False vì addon này không tạo app menu riêng — nó chỉ mở rộng Accounting đã có sẵn.
  • auto_install: False — người dùng phải chủ động chọn từ Apps. Với localization, đây là behavior an toàn: không tự động push chart of accounts Việt Nam vào instance đang dùng chart khác.

Ngoài manifest, ta tạo __init__.py chỉ với một dòng from . import models để Python coi l10n_vn_vat_accounting là một package và load subpackage models/ (hiện tại còn rỗng nhưng sẵn sàng cho step 2).

File test tests/test_manifest.py kiểm tra 7 thứ: addon directory tồn tại, __manifest__.py parse được thành dict, account nằm trong depends, version bắt đầu bằng 16., installable is True, license LGPL-3, category đúng, models subpackage có __init__.py, và hai thư mục placeholder views/ + security/ tồn tại. Bấy nhiêu là đủ để bắt mọi lỗi typo trong manifest trước khi nạp vào Odoo thật.

Kiểm thử

Chạy pytest từ thư mục codebase/:

python3 -m pytest

Output mong đợi:

============================= test session starts ==============================
platform darwin -- Python 3.9.6, pytest-8.4.2, pluggy-1.6.0
rootdir: .../odoo-ke-toan-vat-vietnam/codebase
configfile: pyproject.toml
testpaths: tests
collected 9 items

tests/test_manifest.py .........                                         [100%]

============================== 9 passed in 0.01s ===============================

9 tests pass — manifest hợp lệ, account dependency đã khai báo, và bộ khung thư mục sẵn sàng cho các step kế tiếp.

Kết quả

Sau step 1, repo đã có một Odoo 16 addon l10n_vn_vat_accounting với manifest đầy đủ metadata, phụ thuộc đúng vào module account, và bộ khung thư mục models/, views/, security/ sẵn sàng nhận code của các step sau. Toàn bộ trạng thái được bảo vệ bằng 9 smoke tests chạy bằng pytest thuần, không cần boot một Odoo instance — nhờ đó vòng feedback rất nhanh khi tinh chỉnh manifest ở các step kế tiếp.

Repository

The companion code for this article: https://github.com/vytharion/odoo-ke-toan-vat-vietnam

Key commits to step through:

  • 196cbd9 — step 1: Scaffold the Odoo addon module skeleton with __manifest__.py and account dependency

Step 2: Hệ thống tài khoản kế toán Việt Nam theo Thông tư 200

Bước 2 biến bộ khung addon từ step 1 thành một localization Việt Nam có thể cài được: ta khai báo một account.chart.template mang tên Vietnam - Thong tu 200/2014/TT-BTC Chart of Accounts, đính kèm file CSV account.account.template làm danh sách tài khoản gốc theo Thông tư 200/2014/TT-BTC, và viết nhóm test xác nhận rằng seed này đủ sức nạp vào Odoo 16 mà không bị reject.

Lý do tách bước này riêng: chart of accounts là bất biến cơ sở của mọi nghiệp vụ kế toán về sau. Invoice GTGT (step kế tiếp) phải đặt dòng nợ/có vào tài khoản 511/3331, hạch toán VAT khấu trừ phải chạm đúng 133, Tờ khai 01/GTGT phải cộng dồn từ 33311/33312. Nếu sai mã số hoặc sai account_type ngay từ seed, mọi report bên trên sẽ lệch một cách âm thầm.

Vì vậy ta tách phần seed thành một pipeline chart_loader thuần Python — kiểm chứng được ngoài Odoo, đặt dưới một bộ 41 test data-driven — trước khi nạp vào một Odoo instance thật.

Setup

Trước khi chạm vào code, ta thiết lập layout sau cho addon l10n_vn_vat_accounting:

  • Thư mục data/ chứa hai file seed: account_chart_template_data.xml (record chart template chính, link với base.VND, base.vn, và account.configurable_chart_template) và account.account.template.csv (toàn bộ danh sách tài khoản TT 200 với cột id, code, name, account_type, reconcile, chart_template_id:id).
  • Thư mục models/ lần đầu có nội dung thực: chart_loader.py (dataclass + helper đọc CSV, thuần Python, không import odoo) và account_chart_template.py (lớp _inherit = "account.chart.template" chỉ load dưới Odoo runtime — pytest sẽ bỏ qua nhanh nhờ try/except ImportError).
  • __manifest__.py từ step 1 được cập nhật thêm khóa data với thứ tự account_chart_template_data.xml trước, account.account.template.csv sau. Odoo resolve chart_template_id:id của từng row CSV bằng cách tra cứu external id đã nạp, nên XML phải đứng trước CSV. Nếu đổi chỗ hai dòng này, install sẽ lỗi với External ID not found in the system: l10n_vn_vat_accounting.vn_chart_template_tt200.
  • File test tests/test_chart_of_accounts.py với pytest parametrize cover 21 mã tài khoản bắt buộc — đủ để bắt sai sót khi phiên dịch từ thông tư sang seed (sai tên tiếng Việt, gán nhầm account_type, hay quên một lớp 0 off-balance).

Ta không cài Odoo trong môi trường CI. chart_loader.py không import odoo — chỉ dùng csv, dataclasses, pathlib. Nhờ đó pytest có thể chạy trong 50ms trên laptop mà vẫn check được tính nhất quán của toàn bộ seed.

Phần account_chart_template.py bọc from odoo import models trong try/except ImportError, set models = None, và chỉ declare lớp _inherit khi models is not None. Pattern này cho phép cùng một file vừa chạy được dưới Odoo registry boot vừa không làm vỡ pytest collection.

Triển khai

Trái tim của step 2 là chart_loader.py. Nó định nghĩa AccountTemplate dataclass và các helper iter_account_templates / load_account_templates / find_by_code:

ADDON_ROOT = Path(__file__).resolve().parent.parent
CHART_CSV_PATH = ADDON_ROOT / "data" / "account.account.template.csv"
CHART_TEMPLATE_XML_ID = "l10n_vn_vat_accounting.vn_chart_template_tt200"

VALID_ACCOUNT_TYPES = frozenset({
    "asset_receivable", "asset_cash", "asset_current", "asset_non_current",
    "asset_prepayments", "asset_fixed", "liability_payable",
    "liability_credit_card", "liability_current", "liability_non_current",
    "equity", "equity_unaffected", "income", "income_other", "expense",
    "expense_depreciation", "expense_direct_cost", "off_balance",
})

TT200_CLASS_LABELS = {
    "1": "Tai san ngan han", "2": "Tai san dai han",
    "3": "No phai tra",      "4": "Von chu so huu",
    "5": "Doanh thu",        "6": "Chi phi san xuat, kinh doanh",
    "7": "Thu nhap khac",    "8": "Chi phi khac",
    "9": "Xac dinh ket qua kinh doanh", "0": "Tai khoan ngoai bang",
}

@dataclass(frozen=True)
class AccountTemplate:
    xml_id: str
    code: str
    name: str
    account_type: str
    reconcile: bool
    chart_template_id: str

    @property
    def tt200_class(self) -> str:
        return self.code[0]

    @property
    def class_label(self) -> str:
        return TT200_CLASS_LABELS[self.tt200_class]

Một vài điểm đáng chú ý trong đoạn code trên:

  • VALID_ACCOUNT_TYPES là danh sách 18 giá trị account_type mà Odoo 16 chấp nhận cho account.account (xem odoo/addons/account/models/account_account.py). Ta đặt nó thành frozenset cấp module để test test_every_account_type_is_valid_odoo16_enum có thể kiểm từng row CSV mà không cần mở Odoo. Sai một type — ví dụ đánh tài khoản 511 là revenue thay vì income — Odoo 16 sẽ reject install với ValueError: Wrong value for account.account.account_type.
  • TT200_CLASS_LABELS ánh xạ chữ số đầu của mã tài khoản sang tên lớp theo Thông tư 200 (lớp 1 = tài sản ngắn hạn, lớp 0 = ngoài bảng). Computed property tt200_class chỉ cần code[0] — đủ vì TT 200 dùng hệ cố định: lớp là chữ số đầu tiên, dù mã có 3, 4 hay 5 chữ số.
  • @dataclass(frozen=True) giúp AccountTemplate immutable + hashable, tiện khi cần dedup hay so sánh trong test.

Phía trên pure-Python layer là account_chart_template.py — file duy nhất trong addon phụ thuộc odoo:

try:
    from odoo import models
except ImportError:
    models = None

if models is not None:
    class VnTt200ChartTemplate(models.Model):
        _inherit = "account.chart.template"

        def _l10n_vn_vat_accounting_tt200_xml_id(self) -> str:
            return chart_loader.CHART_TEMPLATE_XML_ID

Ta không tạo model mới — account.chart.template đã do core account cung cấp. Ta chỉ _inherit để gắn một helper method trả về external id chuẩn. Các step sau (ví dụ khi cần match record account.account thực tế với AccountTemplate trong pytest fixture) sẽ có chỗ neo chính thức.

Seed XML thiết lập chart template:

<record id="vn_chart_template_tt200" model="account.chart.template">
    <field name="name">Vietnam - Thong tu 200/2014/TT-BTC Chart of Accounts</field>
    <field name="parent_id" ref="account.configurable_chart_template"/>
    <field name="code_digits">3</field>
    <field name="currency_id" ref="base.VND"/>
    <field name="bank_account_code_prefix">1121</field>
    <field name="cash_account_code_prefix">1111</field>
    <field name="transfer_account_code_prefix">1311</field>
    <field name="property_account_receivable_id" ref="vn_tt200_131"/>
    <field name="property_account_payable_id" ref="vn_tt200_331"/>
    <field name="property_account_expense_categ_id" ref="vn_tt200_632"/>
    <field name="property_account_income_categ_id" ref="vn_tt200_511"/>
</record>

Có ba điểm quan trọng cần giải thích trong XML này:

  • parent_id="account.configurable_chart_template" cho phép Odoo coi chart của ta như một template mà người dùng có thể chọn trong setup wizard.
  • code_digits=3 là mã số cơ sở của Thông tư 200. Dù nhiều sub-account có 4 hoặc 5 chữ số (ví dụ 1331, 33311), Odoo vẫn padding/truncate về 3 khi cần. Các sub-account chuyên biệt ta khai báo trực tiếp với độ dài thật trong CSV.
  • Bốn property_account_*_id thiết lập default account: receivable → 131 (Phải thu của khách hàng), payable → 331 (Phải trả cho người bán), expense categ → 632 (Giá vốn hàng bán), income categ → 511 (Doanh thu bán hàng và cung cấp dịch vụ). Đây là các mặc định đúng nhất với doanh nghiệp thương mại/sản xuất theo TT 200.

CSV seed gồm 86 row từ mã 111 (Tiền mặt) đến 911 (Xác định kết quả kinh doanh), cộng thêm lớp 0 off-balance (001 → 007). Bộ seed đảm bảo đủ lớp 1-9 + 0, và có các sub-account quan trọng cho VAT: 1331 (GTGT khấu trừ hàng hóa, dịch vụ), 1332 (GTGT khấu trừ tài sản cố định), 33311 (GTGT đầu ra), 33312 (GTGT hàng nhập khẩu). Các tài khoản này sẽ được step 3 tham chiếu khi định nghĩa account.tax cho thuế 0%/5%/8%/10%.

Kiểm thử

Chạy pytest từ thư mục codebase/:

python3 -m pytest

Output:

============================= test session starts ==============================
platform darwin -- Python 3.9.6, pytest-8.4.2, pluggy-1.6.0
rootdir: .../odoo-ke-toan-vat-vietnam/codebase
configfile: pyproject.toml
testpaths: tests
collected 50 items

tests/test_chart_of_accounts.py ........................................ [ 80%]
.                                                                        [ 82%]
tests/test_manifest.py .........                                         [100%]

============================== 50 passed in 0.03s ==============================

50 test pass: 9 test manifest từ step 1 vẫn xanh (chứng minh không regress khi thêm khóa data), và 41 test mới chia thành ba nhóm. Nhóm (a) kiểm tra cấu trúc file tồn tại và parse được. Nhóm (b) data-driven check trên 21 mã TT 200 bắt buộc với tên tiếng Việt và account_type mong đợi. Nhóm (c) bao quát toàn bộ seed: không duplicate code/xml_id, type thuộc enum Odoo 16, code là digit 3-5 ký tự, lớp 0-9 đầy đủ, receivable/payable reconcilable, XML well-formed và khai báo VND.

Kết quả

Sau step 2, addon đã có một chart of accounts Việt Nam đầy đủ theo Thông tư 200 sẵn sàng nạp vào Odoo 16. Seed gồm 86 tài khoản trải đều 10 lớp (1-9 + 0 off-balance), 4 property default (receivable, payable, expense categ, income categ), và currency VND.

Toàn bộ seed được bảo vệ bởi 50 pytest case chạy dưới 50ms, không yêu cầu boot Odoo — vòng feedback đủ nhanh để tinh chỉnh mã tài khoản ở các step sau. Helper chart_loader.find_by_code() cũng sẵn sàng cho step 3, khi ta cần kết nối account.tax với tài khoản 1331/1332/33311/33312 mà không phải hardcode chuỗi mã ở nhiều nơi.

Repository

The companion code for this article: https://github.com/vytharion/odoo-ke-toan-vat-vietnam

Key commits to step through:

  • 196cbd9 — step 1: Scaffold the Odoo addon module skeleton with __manifest__.py and account dependency
  • 873369e — step 2: Define the Vietnamese chart of accounts model and seed Thông tư 200 account codes

Step 3: Thuế GTGT 0%, 5%, 8%, 10% và fiscal position mapping

Bước 3 lấp đầy phần thuế giá trị gia tăng bên trên chart of accounts vừa seed ở step 2. Cụ thể là khai báo bốn account.tax.group (VAT 0%, 5%, 8%, 10%), tám account.tax.template chia đều giữa hai chiều nghiệp vụ, và ba account.fiscal.position.template để remap thuế theo đối tác và theo chính sách ưu đãi. type_tax_use = "sale" cho hóa đơn đầu ra, type_tax_use = "purchase" cho hóa đơn đầu vào.

Lý do gộp cả ba khối này vào một step là vì chúng khóa chặt nhau qua external id. Tax template tham chiếu tax group qua tax_group_id, đồng thời tham chiếu tài khoản 33311 và 1331 qua invoice_repartition_line_ids / refund_repartition_line_ids. Fiscal position lại tham chiếu tax template qua tax_src_id + tax_dest_id. Nếu seed tách rời, một lỗi typo bất kỳ cấp nào cũng làm Odoo reject install với External ID not found in the system.

Vì vậy step này vừa thiết kế ba file XML, vừa viết một tax_loader.py thuần Python đọc lại XML và có thể chạy dưới pytest — đảm bảo toàn bộ đồ thị tham chiếu khép kín trước khi chạm vào một Odoo instance thật.

Setup

Trước khi viết XML, ta thiết kế topology của bộ seed và thứ tự nạp vào manifest:

  • data/account_tax_group_data.xml — bốn nhóm thuế. Tên VAT 0% / VAT 5% / VAT 8% / VAT 10% có thể làm tên đầu cột trong report account.tax.report, nên đặt đúng định dạng này (không thêm tiền tố Thuế) để tương thích với widget tax summary sẵn có của Odoo 16.
  • data/account_tax_template_data.xml — tám record: bốn thuế bán hàng và bốn thuế mua vào. Lý do 8% xuất hiện tách riêng thay vì chỉ để fiscal position remap: kế toán thực tế muốn thấy dòng thuế 8% nguyên bản trên hóa đơn GTGT, không phải "10% bị giảm còn 8%". Có tax template riêng cũng giúp Tờ khai 01/GTGT (step sau) cộng dồn chính xác chỉ tiêu số 30 và 30a.
  • data/account_fiscal_position_template_data.xml — ba fiscal position. Position Trong nuoc chỉ đánh dấu (country_id = base.vn, auto_apply = True), không remap thuế — nó tồn tại để Odoo tự chọn mặc định trong form khách hàng VN. Position Xuat khau / Nuoc ngoai remap mọi rate 5/8/10 về 0, cả chiều sale lẫn purchase. Position Giam VAT 10% -> 8% chỉ remap rate 10 → 8, giữ nguyên 5 và 0.
  • Manifest cập nhật khóa data: account_tax_group_data.xml trước, account_tax_template_data.xml sau, account_fiscal_position_template_data.xml sau cùng. Odoo resolve tax_group_id của tax trước khi nạp fiscal position resolve tax_src_id / tax_dest_id — đảo thứ tự sẽ lỗi external id.
  • File test tests/test_vat_taxes.py với 33 case kiểm cấu trúc XML, rate, group binding, account binding, và remapping của fiscal position.

Một điểm kỹ thuật ở phần repartition line: Odoo 16 lưu invoice_repartition_line_idsrefund_repartition_line_ids dưới dạng lệnh One2many qua eval="[(5, 0, 0), (0, 0, {...}), (0, 0, {...})]". Lệnh (5, 0, 0) xóa mọi repartition cũ. Hai lệnh (0, 0, {...}) kế tiếp tạo một dòng base (không gắn account, chỉ đánh dấu dòng cơ sở tính thuế) và một dòng tax (gắn account 33311 hoặc 1331). tax_loader.py giải mã lại chuỗi này bằng regex để pytest có thể kiểm nguyên liệu nguồn trước khi giao cho Odoo registry boot.

Triển khai

account_tax_group_data.xml ngắn gọn, chủ yếu để tạo bốn record và đặt sequence 10/20/30/40 cho thứ tự hiển thị trên widget tax summary:

<record id="vn_tax_group_vat_0" model="account.tax.group">
    <field name="name">VAT 0%</field>
    <field name="sequence">10</field>
</record>
<record id="vn_tax_group_vat_10" model="account.tax.group">
    <field name="name">VAT 10%</field>
    <field name="sequence">40</field>
</record>

Tám record account.tax.template chia thành hai cụm 4-4. Sale (output) post toàn bộ vào 33311:

<record id="vn_vat_sale_10" model="account.tax.template">
    <field name="name">VAT 10% Ban hang</field>
    <field name="description">10% S</field>
    <field name="amount">10</field>
    <field name="amount_type">percent</field>
    <field name="type_tax_use">sale</field>
    <field name="tax_group_id" ref="vn_tax_group_vat_10"/>
    <field name="chart_template_id" ref="vn_chart_template_tt200"/>
    <field name="invoice_repartition_line_ids" eval="[(5, 0, 0), (0, 0, {'repartition_type': 'base'}), (0, 0, {'repartition_type': 'tax', 'account_id': ref('vn_tt200_33311')})]"/>
    <field name="refund_repartition_line_ids" eval="[(5, 0, 0), (0, 0, {'repartition_type': 'base'}), (0, 0, {'repartition_type': 'tax', 'account_id': ref('vn_tt200_33311')})]"/>
</record>

Purchase (input) đổi mục tiêu sang 1331 — Thuế GTGT được khấu trừ của hàng hóa, dịch vụ — còn phần còn lại của field giữ nguyên. Có một điểm cần chú ý: invoice và refund đều trỏ vào cùng một account. Khi một hóa đơn bán hàng bị trả lại (refund), bút toán đảo chiều phải chạm ngược về đúng dòng 33311 đã ghi ban đầu. Nếu không, Tờ khai 01/GTGT khi cộng dồn sẽ ra số net sai vì một bên tăng, một bên không giảm.

Phần fiscal position diễn tả logic remap rõ nhất qua account.fiscal.position.tax.template — đây là một model riêng, mỗi mapping là một record:

<record id="vn_fp_export" model="account.fiscal.position.template">
    <field name="name">Xuat khau / Nuoc ngoai</field>
    <field name="sequence">20</field>
    <field name="auto_apply" eval="True"/>
    <field name="chart_template_id" ref="vn_chart_template_tt200"/>
</record>

<record id="vn_fp_export_map_sale_10" model="account.fiscal.position.tax.template">
    <field name="position_id" ref="vn_fp_export"/>
    <field name="tax_src_id" ref="vn_vat_sale_10"/>
    <field name="tax_dest_id" ref="vn_vat_sale_0"/>
</record>

Position vn_fp_export có sáu record mapping (3 sale + 3 purchase) đưa về 0%. Position vn_fp_vat_reduction_8 chỉ có hai record mapping rate 10 → 8 — một cho sale, một cho purchase. Không gắn country_id cho position xuất khẩu vì đối tượng đích là mọi quốc gia khác Việt Nam. Ngược lại, position ưu đãi giảm VAT vẫn gắn country_id = base.vn bởi vì đây là chính sách trong nước.

Tax loader models/tax_loader.py cung cấp ba hàm load_tax_groups(), load_taxes(), load_fiscal_positions() cùng các dataclass VatTaxGroup, VatTax, RepartitionLine, VatFiscalPosition, FiscalPositionTaxMapping. Phần tinh tế nhất là giải mã chuỗi eval="..." của repartition: ta dùng ba regex tách (0, 0, { ... }), bên trong mỗi cụm lấy repartition_typeref('xml_id') cho account_id. Phương pháp này đủ độ tin cậy vì format do chính Odoo template guide quy định, và test test_repartition_lines_contain_one_base_and_one_tax sẽ bắt ngay nếu seed lệch ra khỏi dạng chuẩn. Helper mapping_for(src_xml_id) của VatFiscalPosition trả về FiscalPositionTaxMapping đầu tiên khớp — đủ để mô phỏng cách Odoo apply fiscal position trong nghiệp vụ thực tế.

Kiểm thử

Chạy pytest từ thư mục codebase/:

python3 -m pytest

Output:

============================= test session starts ==============================
platform darwin -- Python 3.9.6, pytest-8.4.2, pluggy-1.6.0
rootdir: .../odoo-ke-toan-vat-vietnam/codebase
configfile: pyproject.toml
testpaths: tests
collected 83 items

tests/test_chart_of_accounts.py ........................................ [ 48%]
.                                                                        [ 49%]
tests/test_manifest.py .........                                         [ 60%]
tests/test_vat_taxes.py .................................                [100%]

============================== 83 passed in 0.07s ==============================

83 test pass: 9 test manifest và 41 test chart of accounts từ step trước vẫn xanh — chứng minh không regress. 33 test mới của step 3 phủ kín bốn mặt: cấu trúc XML (3 file tồn tại, well-formed, root là <odoo>), tax group (4 record, tên đúng, sequence duy nhất và tăng dần), tax template (8 record với rate + group đúng, tất cả dùng amount_type = percent, sale post 33311, purchase post 1331, invoice và refund cùng trỏ vào một tài khoản, mọi account_id đều tồn tại trong chart CSV), và fiscal position (export remap đầy đủ sale + purchase về 0%, reduction chỉ chạm 10% → 8%, mỗi fiscal position đều link chart_template_id). Hai test cuối kiểm manifest: đảm bảo ba file XML mới có mặt trong khóa data và thứ tự nạp là chart CSV → tax groups → taxes → fiscal positions.

Kết quả

Sau step 3, addon l10n_vn_vat_accounting đã có một bộ thuế GTGT đầy đủ theo quy định hiện hành của Việt Nam: bốn nhóm thuế, tám tax template chia đều cho sale và purchase với binding chính xác về tài khoản 33311 và 1331, và ba fiscal position cover ba kịch bản thực tế — nội địa, xuất khẩu, và ưu đãi giảm VAT theo Nghị quyết 43/2022 + 101/2023.

Toàn bộ seed được bảo vệ bởi 33 pytest mới (cộng 50 test cũ từ step 1-2), chạy dưới 100ms, không yêu cầu boot Odoo. Helper tax_loader.find_tax_by_xml_id()VatFiscalPosition.mapping_for() sẵn sàng dùng lại ở step kế tiếp khi ta dựng hóa đơn GTGT (account.move với move_type in ('out_invoice', 'in_invoice')), cần chọn thuế và apply fiscal position theo đối tác.

Repository

The companion code for this article: https://github.com/vytharion/odoo-ke-toan-vat-vietnam

Key commits to step through:

  • 196cbd9 — step 1: Scaffold the Odoo addon module skeleton with __manifest__.py and account dependency
  • 873369e — step 2: Define the Vietnamese chart of accounts model and seed Thông tư 200 account codes
  • 24b74f8 — step 3: Configure VAT tax records (0%, 5%, 8%, 10%), tax groups, and fiscal position mapping

Step 4: Hoá đơn GTGT — mẫu số, ký hiệu, số hoá đơn tự động

Bước 4 chuyển từ khối thiết lập mặc định (chart of accounts + thuế) sang khối nghiệp vụ chạy hàng ngày: biến account.move thành hóa đơn giá trị gia tăng (hóa đơn GTGT) đúng quy định Việt Nam. Cụ thể là thêm ba field bắt buộc — l10n_vn_invoice_template_code (mẫu số), l10n_vn_invoice_symbol (ký hiệu), và l10n_vn_invoice_number (số hóa đơn GTGT).

Validator regex bảo vệ hai chế độ song song: Thông tư 39/2014/TT-BTC (hóa đơn giấy và hóa đơn điện tử cũ, mẫu số dạng 01GTKT0/001, ký hiệu dạng AA/24E) và Thông tư 78/2021/TT-BTC + Quyết định 1450/QĐ-TCT (hóa đơn điện tử hiện hành, mẫu số một ký tự 1-6, ký hiệu sáu ký tự dạng [CK]YY[A-Z]{3}). Mỗi cặp (company, mẫu số, ký hiệu) được gắn với một ir.sequenceimplementation='no_gap' để số hóa đơn tăng dần từ 1, không được đứt quãng. Nếu nhảy số, cơ quan thuế sẽ coi là mất hóa đơn và xử phạt hành chính theo Nghị định 125/2020/NĐ-CP.

Toàn bộ logic format + cấp số được tách ra một module Python thuần (invoice_sequence.py) để pytest có thể chứng minh hợp đồng monotonic, gap-free trước khi mở Odoo registry.


Setup

Step này tạo bốn file mới, sắp xếp theo trách nhiệm rõ ràng:

  • l10n_vn_vat_accounting/models/invoice_sequence.py — module Python thuần chứa tất cả regex validator (MAU_SO_TT39_RE, KY_HIEU_TT39_RE, MAU_SO_TT78_RE, KY_HIEU_TT78_RE), dataclass InvoiceIdentity xác định chế độ (tt39 / tt78), và dataclass InvoiceNumberSequence mô phỏng counter gap-free trong bộ nhớ.
  • l10n_vn_vat_accounting/models/account_move.py — kế thừa model account.move, thêm ba field GTGT, một @api.constrains kiểm format, một helper _l10n_vn_allocate_invoice_number gọi ir.sequence.next_by_code lúc post, và override _post() để cấp số đúng thời điểm chuyển trạng thái sang posted.
  • l10n_vn_vat_accounting/data/ir_sequence_data.xml — một record ir.sequence mặc định (vn_gtgt_invoice_sequence_default) với implementation = "no_gap", padding = 8, number_next = 1. Triển khai thực tế sẽ tạo thêm một record cho mỗi cặp (company, mẫu số, ký hiệu) qua wizard hoặc giao diện quản trị, nhưng file seed đảm bảo cài đặt từ đầu luôn có ít nhất một cuốn hóa đơn.
  • tests/test_invoice_gtgt.py — 60 test phủ kín sáu mảng: validator mẫu số (TT39 và TT78), validator ký hiệu (TT39 và TT78), nhất quán chế độ (mẫu số và ký hiệu phải cùng một Thông tư), make_sequence_code, counter monotonic gap-free, và cấu trúc XML của file seed.

Cập nhật __manifest__.py: bổ sung "data/ir_sequence_data.xml" ở cuối danh sách data (sau fiscal positions). Cập nhật models/__init__.py: import invoice_sequence trước rồi account_move sau — vì account_move dùng helper của invoice_sequence ở module-level.

Một điểm kỹ thuật quan trọng: file account_move.py phải import an toàn bên ngoài Odoo runtime. Pytest chạy từ thư mục codebase/ không có binary odoo trong sys.path, nếu ta from odoo import api, fields, models trực tiếp thì toàn bộ test suite sẽ crash ngay ở giai đoạn collection. Cách giải quyết là một khối try / except ImportError ở đầu file gán api = fields = models = None, và class VnGtgtAccountMove chỉ được định nghĩa trong nhánh if models is not None. Khi Odoo registry boot, import thành công, class tồn tại; khi pytest import, module vẫn load được, chỉ class là không xuất hiện — đủ để chạy test thuần hàm trên invoice_sequence.


Triển khai

invoice_sequence.py là trái tim của step, mở đầu bằng bốn regex thực thi nguyên bản Thông tư:

MAU_SO_TT39_RE = re.compile(r"^\d{2}[A-Z]{2,5}\d/\d{3}$")
KY_HIEU_TT39_RE = re.compile(r"^[A-Z]{2}/\d{2}[A-Z]$")

MAU_SO_TT78_RE = re.compile(r"^[1-6]$")
KY_HIEU_TT78_RE = re.compile(r"^[CK]\d{2}[A-Z]{3}$")

Bốn pattern này sao chép nguyên văn bằng mã quy định trong Phụ lục 1A Thông tư 78. Mẫu số TT78 1hóa đơn giá trị gia tăng, 2hóa đơn bán hàng, 3hóa đơn bán tài sản công, 4–6 là các loại phiếu xuất đặc thù — nên regex chỉ cho [1-6]. Ký hiệu TT78 C24TAA đọc là: C = có mã của cơ quan thuế (K = không mã), 24 = năm 2024, TAA = ba ký tự serial nhận từ cơ quan thuế.

Validator chính validate_invoice_identity xâu chuỗi hai hàm con và kiểm tra regime coherence — quy tắc then chốt: trong một hóa đơn, mẫu số và ký hiệu phải cùng một Thông tư, không cho phép trộn 01GTKT0/001 (TT39) với C24TAA (TT78):

def validate_invoice_identity(mau_so: str, ky_hieu: str) -> InvoiceIdentity:
    mau_regime = validate_mau_so(mau_so)
    ky_regime = validate_ky_hieu(ky_hieu)
    if mau_regime != ky_regime:
        raise InvoiceFormatError(
            f"mau so {mau_so!r} is {mau_regime} but ky hieu {ky_hieu!r} is "
            f"{ky_regime}; both fields must come from the same regulation"
        )
    return InvoiceIdentity(mau_so=mau_so, ky_hieu=ky_hieu, regime=mau_regime)

make_sequence_codechìa khóa tham chiếu giữa model Odoo và ir.sequence. Mỗi cặp (company, mẫu số, ký hiệu) cần một sequence riêng vì kế toán có thể chạy nhiều cuốn song song — ví dụ một cuốn cho hóa đơn bán lẻ C24TAA, một cuốn cho hóa đơn xuất khẩu C24TAB:

def make_sequence_code(company_id: int, mau_so: str, ky_hieu: str,
                       prefix: str = DEFAULT_SEQUENCE_PREFIX) -> str:
    identity = validate_invoice_identity(mau_so, ky_hieu)
    return f"{prefix}.{company_id}.{identity.mau_so}.{identity.ky_hieu}"

InvoiceNumberSequence là dataclass mô phỏng counter trong bộ nhớ, dùng cho pytest. Field _next_value bắt đầu ở 1, next() trả về giá trị hiện tại rồi tăng thêm một — chính xác là hợp đồng monotonic gap-freeir.sequence với implementation='no_gap' cung cấp trong Odoo:

def next(self) -> str:
    value = self._next_value
    formatted = format_invoice_number(value, self.padding)
    self._last_issued = value
    self._next_value = value + 1
    return formatted

Lớp Odoo VnGtgtAccountMove kế thừa account.move, thêm ba field char — mẫu số + ký hiệu do người dùng nhập, và số hóa đơn readonly=True + copy=False vì do hệ thống cấp:

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

    l10n_vn_invoice_template_code = fields.Char(string="Mau so", tracking=True)
    l10n_vn_invoice_symbol = fields.Char(string="Ky hieu", tracking=True)
    l10n_vn_invoice_number = fields.Char(string="So hoa don GTGT",
                                         copy=False, readonly=True)

Constraint _check_l10n_vn_invoice_identity chỉ chạy trên out_invoice / out_refund — hai loại move_type tạo ra hóa đơn GTGT đầu ra. Hóa đơn mua vào in_invoice vẫn lưu mẫu số / ký hiệu của nhà cung cấp nhưng không qua validator của chính ta, vì đó là chuỗi nhập liệu thủ công chứ không phải chuỗi cấp phát. Quan trọng: constraint bỏ qua khi cả hai field còn trống, để không ép hóa đơn draft mới tạo phải điền ngay.

Hook _post() là điểm cấp số:

def _post(self, soft: bool = True):
    posted = super()._post(soft=soft)
    for move in posted:
        if move.l10n_vn_invoice_template_code and move.l10n_vn_invoice_symbol:
            move._l10n_vn_allocate_invoice_number()
    return posted

Tại sao gọi super()._post() trước rồi mới cấp số? Vì _post() chính là nơi Odoo bảo vệ tính nhất quán kế toán — kiểm cân nợ, kiểm tax line, kiểm account. Nếu không qua được, hóa đơn trả về trạng thái draft và số hóa đơn GTGT không nên bị tiêu thụ, tránh nhảy số. Chỉ sau khi super() thành công, ta mới rút số từ ir.sequence.next_by_code(code). Helper _l10n_vn_allocate_invoice_number còn kiểm if self.l10n_vn_invoice_number: return để phòng ngừa trường hợp post hai lần gây cấp hai số khác nhau.

File seed ir_sequence_data.xml<data noupdate="1">. Khóa noupdate rất quan trọng: nếu admin lỡ chạy -u l10n_vn_vat_accounting sau khi đã có 5000 hóa đơn, ta không muốn Odoo reset number_next về 1. implementation = "no_gap" là sự khác biệt có tính tác động nghiệp vụ: với standard, Odoo đặt số trong cache và có thể bỏ qua số nếu transaction abort; với no_gap, sequence dùng SELECT ... FOR UPDATE trong cùng transaction với insert, hi sinh một phần độ song song để đổi lấy cam kết không bao giờ có lỗ hổng.


Kiểm thử

Chạy pytest từ thư mục codebase/:

python3 -m pytest

Output:

============================= test session starts ==============================
platform darwin -- Python 3.9.6, pytest-8.4.2, pluggy-1.6.0
rootdir: codebase
configfile: pyproject.toml
testpaths: tests
collected 143 items

tests/test_chart_of_accounts.py ........................................ [ 27%]
.                                                                        [ 28%]
tests/test_invoice_gtgt.py ............................................. [ 60%]
...............                                                          [ 70%]
tests/test_manifest.py .........                                         [ 76%]
tests/test_vat_taxes.py .................................                [100%]

============================= 143 passed in 0.09s ==============================

143 test pass: 83 test cũ (chart of accounts + manifest + VAT taxes) vẫn xanh, chứng minh việc mở rộng account.move không làm regress lớp chart và lớp thuế từ các step trước.

60 test mới của step 4 phủ kín nhiều mảng: bốn test parametrize chấp nhận mẫu số TT39 (01GTKT0/001, 01GTKT3/001, 02GTTT0/002, 07KPTQ0/001); sáu test parametrize chấp nhận mẫu số TT78 (1 đến 6); mười test bắt lỗi từ lower-case (01gtkt0/001), dư dấu gạch sai (01-GTKT0/001), thiếu padding (01GTKT0/01), đến số ngoài khoảng TT78 (0, 7, 10). Chuỗi test tương tự cho ký hiệu — chấp nhận AA/24E / C24TAA, từ chối aa/24E / Z24TAA (chỉ C hoặc K hợp lệ).

Ba test regime coherence từ chối mẫu số 1 ghép với ký hiệu AA/24E, và 01GTKT0/001 ghép với C24TAA. Chín test cho InvoiceNumberSequence bao gồm: start tại 1, monotonic 5 lần liên tiếp, padding tùy biến, reset về 1, reset tới start tùy chọn, từ chối mẫu số sai, từ chối padding 0, từ chối reset(start=0), và property code khớp helper. Cuối cùng là bốn test cấu trúc XML (tồn tại, well-formed, có record default với implementation='no_gap', padding=8, number_next=1, number_increment=1) và một test chứng minh account_move.py import được không cần Odoo runtime. Toàn bộ chạy dưới 100 mili giây.


Kết quả

Sau step 4, addon l10n_vn_vat_accounting đã có lớp nghiệp vụ hóa đơn GTGT đầy đủ: model account.move mang ba field Vietnamese-specific, validator regex bảo vệ hai chế độ song song (Thông tư 39/2014 và Thông tư 78/2021), và sequence backend no_gap đảm bảo số hóa đơn tăng dần, không đứt quãng theo đúng Nghị định 125/2020/NĐ-CP.

Phần logic format được tách hoàn toàn khỏi ORM nên pytest chạy 60 case xanh trong dưới 50ms mà không cần boot Odoo. Tầng Odoo (model + sequence) mỏng nhẹ, an toàn import ngoài runtime, và gọi xuống helper Python ở mọi điểm quyết định. Hệ thống giờ đã có thể tạo một hóa đơn GTGT draft, điền mẫu số + ký hiệu, ấn Post, và nhận về số hóa đơn 8 chữ số được cấp phát từ ir.sequence riêng cho cặp (company, mẫu số, ký hiệu) — chính xác giống cách phòng kế toán thực tế quản lý một cuốn hóa đơn.

Step kế tiếp sẽ dùng mặt phẳng này để tạo báo cáo Tờ khai 01/GTGT, cộng dồn từ các hóa đơn đã post theo kỳ thuế.


Repository

The companion code for this article: https://github.com/vytharion/odoo-ke-toan-vat-vietnam

Key commits to step through:

  • 196cbd9 — step 1: Scaffold the Odoo addon module skeleton with __manifest__.py and account dependency
  • 873369e — step 2: Define the Vietnamese chart of accounts model and seed Thông tư 200 account codes
  • 24b74f8 — step 3: Configure VAT tax records (0%, 5%, 8%, 10%), tax groups, and fiscal position mapping
  • 012d2be — step 4: Build the hóa đơn GTGT model with mẫu số / ký hiệu fields and invoice number sequence

Step 5: Đối tác thuế — MST validation và địa chỉ Việt Nam

Bước 5 đẩy mặt phẳng dữ liệu sang chiều đối tác. Mỗi hóa đơn GTGT đã có mẫu số / ký hiệu / số hóa đơn từ step 4, nhưng còn thiếu một chủ thể đúng nghĩa pháp lý: đối tác thuế phải được định danh bằng MST — mã số thuế do Tổng cục Thuế cấp.

Ta mở rộng res.partner với field l10n_vn_mst chấp nhận hai dạng thực tế: 10 chữ số cho một pháp nhân gốc (doanh nghiệp, hợp tác xã, hộ kinh doanh hoặc cá nhân nộp thuế) và 13 chữ số dạng XXXXXXXXXX-YYY cho một đơn vị trực thuộc (chi nhánh, văn phòng đại diện, địa điểm kinh doanh phụ thuộc). Validator kiểm công thức checksum chính thức và từ chối cả các MST có check digit bằng 10 — Tổng cục Thuế không bao giờ cấp loại này.

Bên cạnh MST, ta thêm hai field địa chỉ quận / huyệnphường / xã — hai cấp địa giới mà res.country.state của Odoo (chỉ có tỉnh / thành phố) không nắm giữ. Kèm theo đó là helper format_vn_address xâu chuỗi các thành phần theo đúng trật tự bưu chính Việt Nam: <số nhà + đường>, <phường / xã>, <quận / huyện>, <tỉnh / thành phố>. Toàn bộ logic checksum và format được tách hoàn toàn khỏi ORM trong partner_tax.py, theo cùng mẫu mà invoice_sequence.py đã dùng ở step 4: pytest kiểm chứng đủ 73 case trước khi Odoo boot.

Setup

Step này tạo hai file Python mới cộng một file test, và cập nhật duy nhất một file __init__.py.

l10n_vn_vat_accounting/models/partner_tax.py là module Python thuần, không import odoo. Chứa: hằng MST_CHECKSUM_WEIGHTS = (31, 29, 23, 19, 17, 13, 7, 5, 3) (vector trọng số chính thức của Tổng cục Thuế), regex MST_ROOT_REMST_BRANCH_RE, dataclass MstIdentity (root + branch optional, với các property is_branch, canonical, parent_canonical), exception MstFormatError, và bốn hàm compute_check_digit, validate_mst_checksum, normalize_mst, validate_mst. Phần địa chỉ gồm hằng VN_ADDRESS_PART_NAMES = ("street", "ward", "district", "province"), helper format_vn_address(street, ward, district, province) và wrapper address_dict_to_string(values).

l10n_vn_vat_accounting/models/res_partner.py là Odoo model VnResPartner kế thừa res.partner. Thêm bốn field (l10n_vn_mst, l10n_vn_mst_branch compute store=True, l10n_vn_district, l10n_vn_ward), một @api.constrains("l10n_vn_mst") gọi partner_tax.validate_mst và chuyển MstFormatError thành ValidationError, override create + write để chuẩn hóa MST về dạng canonical, và một phương thức l10n_vn_formatted_address() trả về chuỗi địa chỉ đầy đủ. File mở đầu bằng khối try / except ImportError giống account_move.py của step 4 — nếu pytest import mà không có Odoo thì api = fields = models = None và class chỉ được định nghĩa khi models is not None. Nhờ vậy, test test_res_partner_module_imports_without_odoo pass mà không cần boot Odoo registry.

tests/test_partner_mst.py gồm 59 test phủ kín sáu khía cạnh của validator: ba vector hand-derived (0100109106, 3100159751, 0100150619 — checksum tính tay từ công thức); test compute_check_digit trả về 10 khi sum chia hết cho 11; test loop từ chối mọi shape 000000024X với X từ 0 đến 9; test normalize_mst chấp nhận loose input và emit canonical; test MstIdentity immutability (frozen=True); và bảy test cho format_vn_address.

l10n_vn_vat_accounting/models/__init__.py thêm hai dòng cuối:

from . import partner_tax
from . import res_partner

Thứ tự quan trọng: res_partner import partner_tax ở module-level, nên partner_tax phải được Odoo register trước. Không cập nhật __manifest__.py vì step này không thêm file data/ hoặc views/ — toàn bộ là model code, Python helpers, và tests.

Triển khai

partner_tax.py mở đầu bằng bộ trọng số MST — vector lịch sử từ chính tác quan thuế, đã xuất hiện trong addon l10n_vn của OCA và trong tài liệu tham chiếu của Tổng cục Thuế:

MST_CHECKSUM_WEIGHTS = (31, 29, 23, 19, 17, 13, 7, 5, 3)

ROOT_LENGTH = 10
BRANCH_LENGTH = 3

Công thức checksum: nhân từng chữ số đầu (vị trí 0..8) với trọng số tương ứng, cộng tất cả, lấy modulo 11, check digit = 10 - sum % 11. Khi sum % 11 == 0, kết quả là 10 — một chữ số không tồn tại trong hệ cơ số 10, nên Tổng cục Thuế đơn giản là không bao giờ cấp loại MST đó. Hàm compute_check_digit phản ánh chính xác điều này:

def compute_check_digit(first_nine: str) -> int:
    if len(first_nine) != 9 or not first_nine.isdigit():
        raise MstFormatError(
            f"check-digit input must be 9 digits, got {first_nine!r}"
        )
    weighted = sum(
        int(digit) * weight
        for digit, weight in zip(first_nine, MST_CHECKSUM_WEIGHTS)
    )
    return 10 - weighted % 11

validate_mst_checksum là guard nội bộ: kiểm regex 10 chữ số, tính check digit, so sánh — nếu không khớp hoặc expected = 10 thì raise MstFormatError. Đây là điểm bảo vệ hợp nhất; validate_mst cấp cao hơn chỉ xâu chuỗi normalize, tách root/branch, rồi gọi xuống validator này.

normalize_mst là tầng trung gian: người dùng nhập MST kiểu nào Odoo cũng phải nuốt — có thể là "0100109106", " 0100109106 ", "01 00 10 91 06", "01.00.10.91.06", "010-010-9106", "0100109106-001", hoặc "0100109106001" (13 chữ số liên tiếp). Logic: _strip_separators xóa whitespace, ., -; sau đó kiểm độ dài — nếu 10 thì trả root, nếu 13 thì reformat thành XXXXXXXXXX-YYY, còn không thì raise. Mẫu thiết kế này tách giai đoạn chuẩn hóa ra khỏi giai đoạn validate checksum: bạn chuẩn hóa trước khi lưu, còn checksum chỉ chạy khi cần kiểm thực sự.

def normalize_mst(value: str) -> str:
    cleaned = _strip_separators(value)
    if not cleaned:
        raise MstFormatError("MST is empty")
    if len(cleaned) == ROOT_LENGTH and cleaned.isdigit():
        return cleaned
    if len(cleaned) == ROOT_LENGTH + BRANCH_LENGTH and cleaned.isdigit():
        return f"{cleaned[:ROOT_LENGTH]}-{cleaned[ROOT_LENGTH:]}"
    raise MstFormatError(
        f"MST {value!r} must be 10 digits or 13 digits (XXXXXXXXXX-YYY); "
        f"got {len(cleaned)} digit-like character(s)"
    )

MstIdentity dataclass frozen=True có bốn thuộc tính tính toán: is_branch (boolean có branch hay không), canonical (xâu kết lại với gạch nối), và parent_canonical (luôn là root). Hàm parent_canonical chuẩn bị sẵn cho báo cáo: khi tổng hợp hóa đơn từ nhiều chi nhánh, kế toán cần gộp tất cả đơn vị trực thuộc về một dòng tổng của công ty mẹ, và parent_canonical là khóa group-by tự nhiên.

Phần địa chỉ của partner_tax.py là hai hàm nhỏ nhưng phải đúng canonical:

def format_vn_address(street=None, ward=None, district=None, province=None) -> str:
    parts = []
    for part in (street, ward, district, province):
        if part is None:
            continue
        cleaned = re.sub(r"\s+", " ", str(part)).strip()
        if cleaned:
            parts.append(cleaned)
    return ", ".join(parts)

Trật tự (street, ward, district, province) là trật tự bưu chính Việt Nam đọc từ nhỏ về lớn: số nhà + đường → phường / xã → quận / huyện → tỉnh / thành phố. Khác với mẫu địa chỉ tiếng Anh, ta không có zip trên hóa đơn GTGT, và có thêm tầng phường / xã mà tiếng Anh không có. Hai điều kiện if part is Noneif cleaned cho phép helper degrade gracefully khi chỉ có tỉnh — ví dụ format_vn_address(province="Ha Noi") trả về "Ha Noi" thay vì ", , , Ha Noi".

res_partner.py là lớp mỏng nối partner_tax với Odoo. Bốn field:

class VnResPartner(models.Model):
    _inherit = "res.partner"

    l10n_vn_mst = fields.Char(string="MST (Vietnam)", tracking=True)
    l10n_vn_mst_branch = fields.Char(
        string="MST branch suffix",
        compute="_compute_l10n_vn_mst_branch",
        store=True,
    )
    l10n_vn_district = fields.Char(string="Quan / Huyen")
    l10n_vn_ward = fields.Char(string="Phuong / Xa")

l10n_vn_mst_branchstored computed — không hiện trên form, chỉ rút từ MST để làm khóa join và group-by. store=True cho phép search index theo branch mà không phải recompute mỗi query; @api.depends("l10n_vn_mst") đảm bảo nó cập nhật mỗi khi MST thay đổi. tracking=True trên l10n_vn_mst lưu lịch sử vào chatter — MST là field nhạy cảm thuế, mọi sửa đổi cần audit được.

Constraint _check_l10n_vn_mst chỉ chạy khi MST không rỗng — tạo partner draft mà chưa điền MST vẫn hợp lệ:

@api.constrains("l10n_vn_mst")
def _check_l10n_vn_mst(self) -> None:
    for partner in self:
        if not partner.l10n_vn_mst:
            continue
        try:
            partner_tax.validate_mst(partner.l10n_vn_mst)
        except partner_tax.MstFormatError as exc:
            raise ValidationError(str(exc)) from exc

Vòng for partner in self chạy ngầm vì Odoo có thể gọi constrain trên recordset nhiều partner một lúc khi batch import. raise ... from exc giữ chain cho debug. Override createwrite chèn bộ chuẩn hóa trước khi lưu, nên bảng res_partner lúc nào cũng chứa MST dạng canonical:

@api.model_create_multi
def create(self, vals_list):
    for vals in vals_list:
        if vals.get("l10n_vn_mst"):
            vals["l10n_vn_mst"] = self._l10n_vn_normalize_mst(vals["l10n_vn_mst"])
    return super().create(vals_list)

_l10n_vn_normalize_mst có một điểm tế nhị: nếu MST sai checksum, nó trả về raw thay vì raise. Lý do: @api.constrains sẽ chạy sau create / write và raise ValidationError với thông điệp rõ ràng cho người dùng. Nếu normalize raise trước, người dùng thấy traceback Python xấu xí thay vì popup lỗi của Odoo. Constraint là lớp bảo vệ cuối cùng; normalize chỉ làm việc format khi format có thể làm được.

Helper l10n_vn_formatted_address cộng tỉnh / thành phố từ state_id.name (Odoo đã có res.country.state cho tỉnh, ta không làm lại) với ward và district custom rồi gọi xuống partner_tax.format_vn_address. Odoo layer không tự xâu chuỗi — chỉ xếp dữ liệu vào helper Python thuần.

Kiểm thử

Chạy pytest từ thư mục codebase/:

python3 -m pytest

Output:

============================= test session starts ==============================
platform darwin -- Python 3.9.6, pytest-8.4.2, pluggy-1.6.0
rootdir: codebase
configfile: pyproject.toml
testpaths: tests
collected 202 items

tests/test_chart_of_accounts.py ........................................ [ 19%]
.                                                                        [ 20%]
tests/test_invoice_gtgt.py ............................................. [ 42%]
...............                                                          [ 50%]
tests/test_manifest.py .........                                         [ 54%]
tests/test_partner_mst.py .............................................. [ 77%]
.............                                                            [ 83%]
tests/test_vat_taxes.py .................................                [100%]

============================== 202 passed in 0.37s ==============================

Toàn bộ 202 test pass dưới 400 mili giây. 143 test cũ (chart of accounts + invoice GTGT + manifest + VAT taxes) vẫn xanh, chứng minh việc mở rộng res.partner không regress lớp hóa đơn / chart / thuế.

59 test mới của step 5 phủ kín mọi góc: ba vector hand-derived (0100109106, 3100159751, 0100150619) đối chiếu sum và check digit với kết quả tính tay; test compute_check_digit("000000024") == 10 xác nhận mẫu sum % 11 == 0; test loop chứng minh mọi 000000024X đều fail vì check digit đúng phải là 10; bốn test reject input không phải 9 chữ số; ba test reject mutated check digit (xoay sang (expected + 3) % 10). Mười test normalize_mst chấp nhận loose input (whitespace, dot, gạch nối xen kẽ); bảy test reject shape sai; sáu test validate_mst cho cả root lẫn branch; một test 13 chữ số concatenated ("0100109106002" → canonical "0100109106-002"). Bảy test format_vn_address kiểm trật tự, skip None, skip blank, collapse whitespace, và dict wrapper. Tất cả chạy xanh trong một lần gọi pytest duy nhất.

Kết quả

Sau step 5, l10n_vn_vat_accounting đã có lớp đối tác thuế Việt Nam đầy đủ: res.partner mang bốn field l10n_vn_mst, l10n_vn_mst_branch, l10n_vn_district, l10n_vn_ward; validator MST kiểm checksum theo công thức Tổng cục Thuế và từ chối MST có check digit 10; normalize chuyển loose input về canonical XXXXXXXXXX-YYY trước khi lưu; helper format_vn_address xâu địa chỉ theo trật tự bưu chính Việt Nam.

Nhờ mẫu import-safe (try / except ImportError ở đầu res_partner.py), pytest chạy 59 test mới của step trong dưới 100ms mà không cần boot Odoo. Tính đến step này, ta đã có đủ ba mặt phẳng dữ liệu một tờ khai thuế cần: kế toán (chart of accounts), thuế (tax + fiscal positions), và hóa đơn (account.move + ir.sequence + res.partner). Step 6 sẽ xếp ba mặt phẳng này vào báo cáo Tờ khai thuế GTGT Mẫu 01/GTGT — cộng dồn tất cả hóa đơn GTGT một kỳ thuế, đối chiếu thuế đầu ra theo MST nhà cung cấp, và render QWeb.

Repository

The companion code for this article: https://github.com/vytharion/odoo-ke-toan-vat-vietnam

Key commits to step through:

  • 196cbd9 — step 1: Scaffold the Odoo addon module skeleton with __manifest__.py and account dependency
  • 873369e — step 2: Define the Vietnamese chart of accounts model and seed Thông tư 200 account codes
  • 24b74f8 — step 3: Configure VAT tax records (0%, 5%, 8%, 10%), tax groups, and fiscal position mapping
  • 012d2be — step 4: Build the hóa đơn GTGT model with mẫu số / ký hiệu fields and invoice number sequence
  • 6ff226a — step 5: Extend res.partner with MST (tax code) validation and Vietnamese address fields

Step 6: Tờ khai thuế GTGT Mẫu 01/GTGT — QWeb report

Step 6 là bước kiến thiết báo cáo thuế cuối cùng của module. Tất cả các lớp dữ liệu từ năm step trước — chart of accounts (step 2), VAT taxes + fiscal position (step 3), hóa đơn GTGT với mẫu số / ký hiệu / số hóa đơn (step 4), và MST partner (step 5) — được tổng hợp thành một tờ khai mà kế toán thực sự nộp cho cơ quan thuế.

Cụ thể là Tờ khai thuế GTGT - Mẫu số 01/GTGT quy định tại Phụ lục II Thông tư 80/2021/TT-BTC: báo cáo định kỳ (tháng hoặc quý) gồm mười bảy chỉ tiêu đánh số [22], [23], [24][40], [43], từ thuế đầu vào được khấu trừ kỳ trước chuyển sang cho đến thuế GTGT còn được khấu trừ chuyển sang kỳ sau.

Kiến trúc bước này gồm ba tầng. Engine thuần Python vat_declaration.py — không một dòng Odoo ORM — với hàm compute_declaration(records, period, prior_carryover) nhận list InvoiceLineRecord và trả về VatDeclaration. Bên trên là TransientModel l10n.vn.vat.declaration nối engine với ORM. Trên cùng là QWeb template render tờ khai đúng định dạng mẫu in chính thức, kèm menu wiring gắn vào account.menu_finance_reports.

Tách pure-Python ra cho phép pytest kiểm chứng tất cả công thức mà không cần boot Odoo registry — một mẫu đã dùng từ step 4 (invoice_sequence.py) và step 5 (partner_tax.py), lần này áp dụng cho mặt phẳng phức tạp nhất: tổng hợp nhiều hóa đơn nhiều thuế suất thành chỉ tiêu của tờ khai.

Setup

Step này tạo ba file Python mới, hai file XML mới, và cập nhật hai file cũ.

l10n_vn_vat_accounting/models/vat_declaration.py — module pure-Python, KHÔNG import odoo. Chứa: hằng SALE = "sale", PURCHASE = "purchase", DIRECTIONS, TAXABLE_RATES = (0, 5, 8, 10), tuple DECLARATION_INDICATORS 17 phần tử (code + nhãn tiếng Việt) theo đúng trật tự in của mẫu 01/GTGT, exception DeclarationFormatError, dataclass DeclarationPeriod với classmethod monthly(year, month)quarterly(year, quarter) cùng property contains(date), dataclass InvoiceLineRecord với __post_init__ validate direction + rate + date, dataclass DeclarationLine (code, label, value), dataclass VatDeclaration với method get_line, value, to_dict, và hàm chính compute_declaration.

l10n_vn_vat_accounting/models/l10n_vn_vat_declaration.py — Odoo wizard mỏng nối vat_declaration với ORM. File mở đầu bằng khối try / except ImportError giống account_move.pyres_partner.py — nếu pytest import mà không có Odoo runtime, api = fields = models = NoneUserError = Exception, class chỉ được định nghĩa khi models is not None. Hàm build_period(mode, year, number) là entry point thuần Python gọi xuống DeclarationPeriod.monthly hoặc DeclarationPeriod.quarterly. Trong block Odoo, VnVatDeclarationWizard(models.TransientModel)_name = "l10n.vn.vat.declaration", các field period_mode (selection tháng/quý), period_year, period_number, period_label (readonly, điền sau khi compute), prior_carryover (Monetary), company_id (Many2one default self.env.company), currency_id (related), và mười bảy field indicator_22indicator_43 toàn bộ đều Monetary readonly.

l10n_vn_vat_accounting/reports/l10n_vn_vat_declaration_template.xml — file XML mới gồm hai record: ir.actions.reportreport_type = "qweb-pdf" với binding_model_id trỏ tới model_l10n_vn_vat_declaration, và QWeb template report_l10n_vn_vat_declaration_document t-call web.html_container + web.external_layout. Template render bằng table-bordered với class CSS o_vn_vat_declaration_table, hai CSS hook o_vn_vat_subtotal (cho [25], [27], [28]) và o_vn_vat_total (cho [40], [43]) cho phép style tách biệt ở lớp layout. Mỗi cell số dùng t-options='{"widget": "monetary", "display_currency": doc.currency_id}' để Odoo tự format VND.

l10n_vn_vat_accounting/views/l10n_vn_vat_declaration_views.xml — file XML mới gồm: ir.ui.view form view với header chứa hai button action_compute (oe_highlight) và action_print_report (chỉ hiện khi period_label đã có), và body chia group Chọn kỳ kê khai cùng group Chỉ tiêu Mẫu 01/GTGT chia bốn sub-group (Đầu vào, Đầu ra theo thuế suất, Tổng hợp đầu ra, Kết quả); một ir.actions.act_window với target = "new"; và một menuitem gắn vào account.menu_finance_reports với sequence = 200.

tests/test_vat_declaration.py — 74 test phủ kín sáu mươi bảy công thức + factory period + XML structure, bao gồm: test DeclarationPeriod.monthly cho tháng ngắn / tháng dài / February năm nhuận, test quarterly Q1/Q4, reject boundary sai; các test InvoiceLineRecord reject direction sai, rate ngoài (0,5,8,10), invoice_date không phải datetime.date; các test compute_declaration với synthetic invoice mix; và một test wiring xác nhận l10n_vn_vat_declaration import được khi không có odoo package.

Hai file cũ được cập nhật:

  • l10n_vn_vat_accounting/models/__init__.py — thêm from . import vat_declaration rồi from . import l10n_vn_vat_declaration. Thứ tự quan trọng: wrapper Odoo phải import pure-Python module trước.
  • l10n_vn_vat_accounting/__manifest__.py — bổ sung "reports/l10n_vn_vat_declaration_template.xml""views/l10n_vn_vat_declaration_views.xml" vào data. Template phải load trước views vì action_report.binding_model_id trỏ tới model lúc Odoo đã boot xong.

Triển khai

Cốt lõi của step này là vat_declaration.py — engine pure-Python. Tuple chỉ tiêu được khai báo theo đúng trật tự in của mẫu 01/GTGT, nhờ thế QWeb template chỉ cần t-foreach="doc.lines" là đủ, không cần resort:

DECLARATION_INDICATORS: Tuple[Tuple[str, str], ...] = (
    ("22", "Thue GTGT con duoc khau tru ky truoc chuyen sang"),
    ("23", "Gia tri HHDV mua vao"),
    ("24", "Thue GTGT HHDV mua vao"),
    ("25", "Tong thue GTGT dau vao duoc khau tru"),
    ("26", "HHDV ban ra khong chiu thue GTGT"),
    ("27", "Tong doanh thu HHDV ban ra chiu thue"),
    ("28", "Tong thue GTGT cua HHDV ban ra"),
    ...
)

compute_declaration là trái tim của engine. Trước hết nó validate prior_carryover >= 0 — chỉ tiêu [22] không được âm vì Mẫu 01/GTGT không encode tình huống âm (nếu âm tức kỳ trước đã khai sai). Sau đó filter records về đúng kỳ qua period.contains(invoice_date). Phần tính được bày ra phẳng, không nested if/else quá hai tầng:

sale_base_0 = _sale_base_at(in_period, 0)
sale_base_5 = _sale_base_at(in_period, 5)
sale_base_8 = _sale_base_at(in_period, 8)
sale_base_10 = _sale_base_at(in_period, 10)
sale_tax_5 = _sale_tax_at(in_period, 5)
sale_tax_8 = _sale_tax_at(in_period, 8)
sale_tax_10 = _sale_tax_at(in_period, 10)
sale_tax_8_10 = sale_tax_8 + sale_tax_10
sale_taxable_base = sale_base_0 + sale_base_5 + sale_base_8 + sale_base_10
sale_taxable_tax = sale_tax_5 + sale_tax_8_10
sale_grand_total = sale_exempt_base + sale_taxable_base

deductible = purchase_tax + prior
delta = sale_taxable_tax - deductible

Net payable / carryover là một block if delta >= ZERO duy nhất — không có try / except lồng, không có tầng nested thứ hai:

if delta >= ZERO:
    vat_payable = delta
    vat_carryover = ZERO
else:
    vat_payable = ZERO
    vat_carryover = -delta

Ý nghĩa của hai chỉ tiêu cuối rất tế nhị: [40] là số thuế kế toán phải chuyển vào tài khoản 3331 và nộp NSNN, [43] là phần carryover kế toán giữ lại làm đầu vào kỳ sau. Hai chỉ tiêu này loại trừ nhau — luôn có đúng một trong hai bằng 0. Đây là ràng buộc báo cáo chính thức, và phép gán nhị nguyên delta >= 0 tạo thành một invariant cấu hình[40] * [43] == 0 luôn thỏa.

Tách gộp chỉ tiêu [33] là một điểm tế nhị khác. Theo Mẫu 01/GTGT, thuế GTGT của HHDV bán ra chịu thuế suất 8% và 10% được báo cáo chung một dòng, vì 8% chỉ là mức giảm thuế tạm thời từ Nghị quyết Quốc hội (Nghị định giảm 2% GTGT). Hai mức thuế đổ vào cùng tài khoản 3331 và cùng bucket báo cáo, nên sale_tax_8_10 = sale_tax_8 + sale_tax_10 là phép cộng đúng chứ không phải workaround.

Wrapper Odoo trong l10n_vn_vat_declaration.py có điểm tế nhị riêng. _INDICATOR_FIELDS là dict {code: field_name} build một lần từ DECLARATION_INDICATOR_CODES, với helper _field_name chuyển "32a""indicator_32_a" vì Python không cho chữ số liền chữ cái trong tên field theo PEP 8. Method action_compute truyền prior_carryover or 0 xuống engine để Odoo Monetary value False (Odoo coi 0.0 default là False trong cache) không gây TypeError trong Decimal(False):

declaration = vat_declaration.compute_declaration(
    records, period, prior_carryover=self.prior_carryover or 0
)
values = declaration.to_dict()
updates = {"period_label": period.label}
for code, field_name in _INDICATOR_FIELDS.items():
    updates[field_name] = float(values[code])
self.write(updates)

_line_rate(line) là lớp chuyển đổi quan trọng nhất: từ account.tax (record Odoo đã khai báo từ step 3) sang integer rate mà engine pure-Python hiểu. Logic đơn giản — lấy taxes[0].amount, round int, kiểm tra in TAXABLE_RATES. Nếu line không gắn tax (if not taxes:return None) thì line đó là exempt sale đóng vào chỉ tiêu [26]. Lấy duy nhất taxes[0] là ràng buộc chủ ý: hóa đơn GTGT chỉ có một tax line mỗi base line, sát với cách Odoo l10n của các quốc gia khác (Pháp, Mexico) chuẩn hóa rate.

QWeb template thiết kế theo phong báo cáo thuế chính thống: hai dòng tiêu đề song ngữ (tiếng Việt hoa, Latin in nghiêng cho cấp quốc tế), hộp Người nộp thuếKỳ kê khai ở hai cột, một bảng table-bordered chia ba cột (Chỉ tiêu 10%, Nội dung 65%, Giá trị (VND) 25%), và dòng chữ ký ở dưới cùng. Các row subtotal ([25], [27], [28]) được gắn class o_vn_vat_subtotal với <strong>, hai row tổng cuối ([40], [43]) được gắn class o_vn_vat_total cũng in đậm. Khi render qua wkhtmltopdf, mẫu in đủ sát bản tờ khai chuẩn để kế toán dùng trực tiếp.

View XML có điểm đáng chú ý: hai button trong header dùng attrs="{'invisible': [('period_label', '=', False)]}" để ẩn khi chưa compute lần nào. Điều này tránh tình huống người dùng bấm Print ngay khi chưa tổng hợp, gây lỗi data rỗng trong PDF. Form view đặt target="new" ở action làm wizard mở dạng dialog modal — phù hợp cho transient model sống ngắn, không lưu dài hạn trong DB.

Kiểm thử

Chạy pytest từ thư mục codebase/:

python3 -m pytest

Output:

============================= test session starts ==============================
platform darwin -- Python 3.9.6, pytest-8.4.2, pluggy-1.6.0
rootdir: .../odoo-ke-toan-vat-vietnam/codebase
configfile: pyproject.toml
testpaths: tests
collected 276 items

tests/test_chart_of_accounts.py ........................................ [ 14%]
.                                                                        [ 14%]
tests/test_invoice_gtgt.py ............................................. [ 31%]
...............                                                          [ 36%]
tests/test_manifest.py .........                                         [ 39%]
tests/test_partner_mst.py .............................................. [ 56%]
.............                                                            [ 61%]
tests/test_vat_declaration.py .......................................... [ 76%]
................................                                         [ 88%]
tests/test_vat_taxes.py .................................                [100%]

============================== 276 passed in 0.51s ==============================

Tất cả 276 test xanh dưới nửa giây. 202 test cũ của step 1–5 (chart of accounts, hóa đơn GTGT, manifest, partner MST, VAT taxes) vẫn pass, chứng minh việc thêm lớp báo cáo Mẫu 01/GTGT không regress bất kỳ lớp nào trước đó.

74 test mới của step 6 phủ kín toàn bộ engine. Ba test DeclarationPeriod.monthly cho tháng 31 ngày, tháng 30 ngày, và February năm nhuận / không nhuận; bốn test parametrize reject month sai (0, 13, -1, 100); hai test quarterly Q1/Q4 cùng boundary check; mười test period.contains inclusive hai đầu, exclusive ngoài biên. Phía InvoiceLineRecord: reject direction = "transfer", rate = 7 (ngoài tập (0,5,8,10)), invoice_date là string thay vì datetime.date. Mười bảy test công thức từng chỉ tiêu — [22] prior carryover passthrough, [25] = [24] + [22], [27] = [29] + [30] + [32] + [32a], [40] = max(0, [35] - [25]), [43] = max(0, [25] - [35]). Tám test mix scenario (chỉ sale, chỉ purchase, vat payable, vat carryover, prior carryover lăn vào deductible, period filter loại invoice ngoài kỳ, năm nhuận, 8%/10% gộp chỉ tiêu [33]). Ba test cấu trúc XML (template well-formed, action_report record đúng id, view + menu reference đúng). Và một test wiring xác nhận wrapper Odoo import được khi không có odoo package.

Kết quả

Sau step 6, l10n_vn_vat_accounting đã có báo cáo thuế định kỳ chính thức đầu tiên. Kế toán mở Accounting → Reports → Mẫu 01/GTGT, chọn kỳ kê khai (tháng hoặc quý), nhập prior carryover, bấm Tổng hợp số liệu — Odoo tự động scan toàn bộ account.move posted của công ty trong khoảng ngày tương ứng, kết nối với VAT tax từ step 3 và MST từ step 5, rồi điền đúng mười bảy chỉ tiêu [22]..[43]`.

Bấm In Mẫu 01/GTGT render PDF đúng định dạng Mẫu số 01/GTGT của Thông tư 80/2021/TT-BTC, với tiêu đề song ngữ, bảng bordered, và dòng chữ ký chính thức. Engine vat_declaration.py thuần Python làm toàn bộ phép tính nên 74 test pytest chạy dưới 100ms không cần boot Odoo registry, cho phép iterate công thức nhanh khi có Thông tư sửa đổi — ví dụ khi mức thuế 8% giảm thuế được gia hạn hoặc chấm dứt, chỉ cần cập nhật TAXABLE_RATES và re-run test.

Tính đến step này, module đã có đầy đủ bốn tầng mà Mẫu 01/GTGT cần: kế toán (chart of accounts), thuế (VAT records + fiscal position), hóa đơn (account.move + ir.sequence + số hóa đơn), và đối tác (MST + địa chỉ). Lớp báo cáo trên cùng — tờ khai nộp cơ quan thuế — là giới hạn phía ngoài của module: tất cả data flow của chu trình GTGT nối với nhau qua một điểm duy nhất là compute_declaration, và luôn có thể kiểm chứng end-to-end qua pytest mà không phụ thuộc Odoo runtime.

Repository

The companion code for this article: https://github.com/vytharion/odoo-ke-toan-vat-vietnam

Key commits to step through:

  • 196cbd9 — step 1: Scaffold the Odoo addon module skeleton with __manifest__.py and account dependency
  • 873369e — step 2: Define the Vietnamese chart of accounts model and seed Thông tư 200 account codes
  • 24b74f8 — step 3: Configure VAT tax records (0%, 5%, 8%, 10%), tax groups, and fiscal position mapping
  • 012d2be — step 4: Build the hóa đơn GTGT model with mẫu số / ký hiệu fields and invoice number sequence
  • 6ff226a — step 5: Extend res.partner with MST (tax code) validation and Vietnamese address fields
  • 018c57a — step 6: Implement the Tờ khai thuế GTGT (Mẫu 01/GTGT) report view with QWeb and aggregation logic

Step 7: Xuất XML HTKK, bảo mật và demo data

Step 7 là step khép kín chu trình của module. Sáu step trước tạo ra toàn bộ dữ liệu — chart of accounts, VAT tax, hóa đơn GTGT, MST partner, và Tờ khai Mẫu 01/GTGT — nhưng dữ liệu vẫn chỉ nằm trong DB Odoo. Step này đưa dữ liệu ra khỏi Odoo bằng đúng con đường mà cơ quan thuế công nhận: serialize toàn bộ tờ khai sang HTKK XML envelope (HSoThueDTuHSoKhaiThue) mà desktop client HTKK 5.x và iHTKK / eTax web portal của Tổng cục Thuế cùng nhận.

Cùng step này, ta đặt ranh giới quyền rõ ràng: Accountant (account.group_account_invoice) được draft và compute tờ khai nhưng không được unlink, còn Accounting Manager (account.group_account_manager) giữ full control và là group duy nhất thấy button Xuất XML HTKK. Hai ir.rule đi kèm chặn multi-company leak và lock-after-export — sau khi xml_file đã có binary content, junior accountant mất write, tránh tình huống sửa số liệu sau khi file đã gửi ra cơ quan thuế.

Cuối cùng ta ship demo data gồm hai partner Việt Nam và một draft declaration Quý 1/2024, để reviewer chạy odoo -i l10n_vn_vat_accounting --without-demo=False là thấy được cycle thực tế mà không phải setup từ zero. Module serializer được tách thành vat_declaration_xml.py pure-Python cho phép pytest validate XML structure trong 36 test mới chạy dưới 100ms.

Setup

Step này tạo ba file mới và cập nhật bốn file cũ:

  • l10n_vn_vat_accounting/models/vat_declaration_xml.py — module pure-Python không import odoo. Chứa constants (DECLARATION_FORM_CODE = "01/GTGT", DECLARATION_TYPE_ORIGINAL = "C", DECLARATION_TYPE_AMENDED = "B"), toàn bộ tag name lấy từ HTKK 5.x schema (ROOT_TAG = "HSoThueDTu", DECLARATION_TAG = "HSoKhaiThue", INDICATORS_TAG = "CTieuTKhaiChinh", v.v.), và dataclass TaxpayerProfile(frozen=True) với mst, name bắt buộc cùng các field Optional cho address, district, province, phone, email. Hai entry point chính: build_declaration_element trả về ET.Element cho test introspect, và build_declaration_xml serialize sang bytes với XML declaration. Helper suggested_filename trả về token kiểu ToKhai_01GTGT_<mst>_<MM_YYYY>.xml cho download attachment.

  • l10n_vn_vat_accounting/security/ir.model.access.csv — hai dòng access rule: access_l10n_vn_vat_declaration_user cấp read=1 write=1 create=1 unlink=0 cho account.group_account_invoice, và access_l10n_vn_vat_declaration_manager cấp full 1,1,1,1 cho account.group_account_manager. Đây là trung tâm policy của step: junior accountant được create + edit draft, nhưng chỉ manager được unlink — tránh nguy cơ xóa tờ khai sau khi file đã gửi cơ quan thuế.

  • l10n_vn_vat_accounting/security/l10n_vn_vat_security.xml — hai ir.rule global. Rule 1 rule_l10n_vn_vat_declaration_company với domain_force = [('company_id', 'in', company_ids)] chặn multi-company leak. Rule 2 rule_l10n_vn_vat_declaration_lock_after_export với domain_force = [('xml_file', '=', False)] chỉ áp dụng cho group account.group_account_invoice — junior accountant chỉ có write quyền trên rows mà xml_file vẫn là False, còn manager group không có restriction này nên giữ nguyên full control.

  • l10n_vn_vat_accounting/demo/l10n_vn_vat_demo.xml — file XML mới với data noupdate="1" chứa hai res.partner: demo_partner_customer_abcCông ty CP ABC Việt Nam MST 0101234567 Quận 1 TP. HCM, và demo_partner_supplier_xyzCông ty TNHH XYZ Logistics MST 0107654321 Quận Hoàn Kiếm Hà Nội. Cả hai đều country_id ref="base.vn". Kèm theo một l10n.vn.vat.declaration draft trỏ Quý 1/2024 (period_mode="quarter" period_year="2024" period_number="1" declaration_type="C") để cài đặt --with-demo là mở wizard ra thấy dữ liệu mẫu ngay.

  • tests/test_vat_declaration_xml.py — 36 test phủ kín sáu nhóm cấu trúc: TaxpayerProfile guard, element graph, indicator block, serialization + wiring, ir.model.access.csv, security.xml, demo data, và manifest. Chi tiết từng nhóm được mô tả trong phần Kiểm thử.

  • l10n_vn_vat_accounting/__manifest__.py — thêm "security/ir.model.access.csv""security/l10n_vn_vat_security.xml"đầu list data (Odoo bắt buộc access rule load trước bất kỳ file nào reference model — nếu views/data file có groups reference trước khi access rule existed, Odoo registry blow up tại boot). Thêm "demo/l10n_vn_vat_demo.xml" vào list demo.

  • l10n_vn_vat_accounting/models/__init__.py — thêm from . import vat_declaration_xml trước dòng from . import l10n_vn_vat_declaration, vì wrapper Odoo import vat_declaration_xml ở module level cho declaration_type selection field.

  • l10n_vn_vat_accounting/models/l10n_vn_vat_declaration.py — thêm ba field mới (declaration_type Selection C/B, xml_filename Char readonly, xml_file Binary readonly), helper _snapshot_declaration build lại VatDeclaration từ stored values (không recompute), _taxpayer_profile map self.company_id.partner_id sang TaxpayerProfile, và action action_export_xml build + base64-encode XML rồi trả về ir.actions.act_url để browser download file .xml.

  • l10n_vn_vat_accounting/views/l10n_vn_vat_declaration_views.xml — thêm button Xuất XML HTKK trong header với groups="account.group_account_manager", thêm group Tệp XML HTKK hiển thị khi xml_filename có value, và thêm field declaration_type vào group Chọn kỳ kê khai.

Triển khai

Cốt lõi của step 7 là vat_declaration_xml.py — pure-Python builder dùng xml.etree.ElementTree stdlib thay vì lxml, vì HTKK schema không yêu cầu XSD validation tại client side và stdlib ET đủ để kế toán upload. Cấu trúc envelope được build dần từ trong ra ngoài:

def build_declaration_element(
    declaration: vat_declaration.VatDeclaration,
    taxpayer: TaxpayerProfile,
    declaration_type: str = DECLARATION_TYPE_ORIGINAL,
) -> ET.Element:
    if declaration_type not in DECLARATION_TYPES:
        raise vat_declaration.DeclarationFormatError(...)

    root = ET.Element(ROOT_TAG)
    body = ET.SubElement(root, DECLARATION_TAG)
    _build_header(body, taxpayer, declaration.period, declaration_type)
    _build_indicator_block(body, declaration)
    return root

Khi build CTieuTKhaiChinh, ta iterate đúng tuple DECLARATION_INDICATOR_CODESvat_declaration module step 6 đã export — không hardcode 17 tên ở đây. Nghĩa là khi Thông tư sửa đổi (ví dụ thêm chỉ tiêu [44] cho hoàn thuế), chỉ cần sửa vat_declaration.DECLARATION_INDICATORS và XML builder tự cập nhật theo:

def _build_indicator_block(parent, declaration):
    block = ET.SubElement(parent, INDICATORS_TAG)
    for code in vat_declaration.DECLARATION_INDICATOR_CODES:
        child = ET.SubElement(block, _indicator_tag(code))
        child.text = _format_amount(declaration.value(code))

_format_amount(value) quantize Decimal về 1 (zero decimal places) rồi cast int rồi str. Đây là ràng buộc của HTKK: số tiền VND luôn là integer, không dấu phẩy, không dấu chấm. Ví dụ Decimal("1500.49") trở thành "1500" chứ không "1,500.49" hay "1500.49". Test test_indicator_block_renders_decimal_amounts_as_integer_vnd xác nhận chuỗi output không chứa , lẫn ..

_period_code là một điểm tế nhị quan trọng: HTKK kỳ vọng period token kiểu MM/YYYY cho monthly và Q/YYYY cho quarterly — không phải Q1/2024 hay T03/2024. Ta parse từ period.label (ví dụ "Thang 3/2024" hay "Quy 1/2024") bằng label.split()[1].split("/") chứ không dùng regex. Với monthly là "03/2024", quarterly là "1/2024" (một chữ số, không padding zero). Hai test test_declaration_meta_carries_form_code_and_period_for_monthlytest_declaration_meta_period_token_for_quarterly lock chính xác hai format này.

_append_text_child(parent, tag, text) chỉ tạo child element khi text là non-empty string. Ý nghĩa là các optional field trong TaxpayerProfile như district, phone, email — nếu không điền, XML sẽ không render empty tag <dchiNNT/> (HTKK 5.x reject empty tag ở một số field nhất định). Test test_optional_taxpayer_fields_are_omitted_when_blank chứng minh: profile chỉ có MST + name thì XML không có dchiNNT lẫn emailNNT.

Security của step 7 dựa vào hai rule phụ thuộc nhau. Rule lock_after_export chỉ có ý nghĩa khi xml_file là persistent field; rule per-company chỉ an toàn khi company_id là required. Cả hai điều kiện đều đã có từ step 6 và step 7. Ta đặt cả hai trong data noupdate="1" để upgrade module không overwrite custom rule của deployment:

<record id="rule_l10n_vn_vat_declaration_lock_after_export"
        model="ir.rule">
    <field name="domain_force">[('xml_file', '=', False)]</field>
    <field name="groups"
           eval="[(4, ref('account.group_account_invoice'))]"/>
</record>

_snapshot_declaration trong wrapper Odoo là điểm tế nhị về tính nhất quán dữ liệu: method này tải lại VatDeclaration từ stored values chứ không gọi lại compute_declaration. Nếu giữa lúc user bấm Tổng hợp số liệu và bấm Xuất XML HTKK có posted invoice mới (ví dụ qua background cron), recompute sẽ cho ra con số khác mà user không ngờ. Snapshot từ stored values đảm bảo what you see is what you export:

def _snapshot_declaration(self) -> vat_declaration.VatDeclaration:
    period = self._build_period()
    values = self._stored_values_as_decimal()
    lines = tuple(
        vat_declaration.DeclarationLine(code=code, label=label, value=values[code])
        for code, label in vat_declaration.DECLARATION_INDICATORS
    )
    return vat_declaration.VatDeclaration(
        period=period, lines=lines, prior_carryover=values["22"],
    )

action_export_xml trả về ir.actions.act_url trỏ tới /web/content/?model=...&id=...&field=xml_file&filename_field=xml_filename&download=true — endpoint mặc định của Odoo để serve binary field như HTTP download. Browser nhận response Content-Disposition: attachment với đúng tên file từ xml_filename, và user thấy popup Save As ToKhai_01GTGT_0101234567_1_2024.xml. Không cần implement controller riêng.

View XML thêm button Xuất XML HTKK với groups="account.group_account_manager": junior accountant không hề thấy button đó vì Odoo render conditionally tại server side. Đây là lớp bảo vệ thứ nhất (UI). Lớp thứ hai là security rule (access_l10n_vn_vat_declaration_user với perm_unlink=0). Lớp thứ ba là lock_after_export rule. Ba lớp này cùng bảo đảm một invariant duy nhất: đã export thì dữ liệu không sửa được trừ manager — phù hợp với ràng buộc kiểm toán thuế.

Kiểm thử

Chạy pytest từ thư mục codebase/:

python3 -m pytest

Output:

============================= test session starts ==============================
platform darwin -- Python 3.9.6, pytest-8.4.2, pluggy-1.6.0
rootdir: .../odoo-ke-toan-vat-vietnam/codebase
configfile: pyproject.toml
testpaths: tests
collected 312 items

tests/test_chart_of_accounts.py ........................................ [ 12%]
.                                                                        [ 13%]
tests/test_invoice_gtgt.py ............................................. [ 27%]
...............                                                          [ 32%]
tests/test_manifest.py .........                                         [ 35%]
tests/test_partner_mst.py .............................................. [ 50%]
.............                                                            [ 54%]
tests/test_vat_declaration.py .......................................... [ 67%]
................................                                         [ 77%]
tests/test_vat_declaration_xml.py ....................................   [ 89%]
tests/test_vat_taxes.py .................................                [100%]

============================== 312 passed in 0.57s ==============================

Tất cả 312 test xanh dưới nửa giây. 276 test của step 1–6 vẫn pass, chứng minh việc thêm lớp XML export, security, và demo data không regress bất kỳ lớp nào trước đó.

36 test mới của step 7 phủ kín sáu nhóm cấu trúc: ba test TaxpayerProfile guard (reject empty MST, reject whitespace-only name, optional default None); bảy test element graph (HSoThueDTu root, một HSoKhaiThue child duy nhất, taxpayer block đúng MST + tên + địa chỉ + điện thoại + email, optional fields omitted khi rỗng, meta đúng maTKhai="01/GTGT" + period token monthly "03/2024" và quarterly "1/2024", loaiTKhai round-trip C/B, invalid declaration type raise DeclarationFormatError); ba test indicator block (17 child tag đúng trật tự ct22..ct43, giá trị match decl.value(code), format integer VND không dấu chấm/phẩy).

Năm test serialization + wiring xác nhận bytes well-formed UTF-8, XML prolog declared, suggested_filename chứa MST + period, strip dấu - của chi nhánh MST, và models/__init__ cùng wrapper Odoo đồng bộ. Bốn test ir.model.access.csv kiểm tra file tồn tại, invoice group 1,1,1,0, manager group 1,1,1,1, và hai dòng tách permission. Ba test security.xml xác nhận well-formed, per-company rule có company_ids trong domain, lock-after-export rule target đúng invoice group và reference field xml_file. Năm test demo data và bốn test manifest đóng kín phần còn lại. Toàn bộ test chạy không cần boot Odoo registry vì tất cả module pure-Python đều import-safe.

Kết quả

Sau step 7, l10n_vn_vat_accounting đã sẵn sàng triển khai sản xuất. Kế toán mở Accounting → Reports → Mẫu 01/GTGT, chọn kỳ kê khai, bấm Tổng hợp số liệu, và bây giờ có thêm action Xuất XML HTKK (chỉ manager thấy) build envelope HSoThueDTu chuẩn HTKK 5.x với MST + tên công ty + địa chỉ từ res.company.partner_id, period token MM/YYYY hoặc Q/YYYY, và 17 chỉ tiêu <ctNN> với giá trị integer VND. File ToKhai_01GTGT_<MST>_<period>.xml download trực tiếp từ browser, sẵn sàng upload vào HTKK desktop hoặc iHTKK / eTax portal của Tổng cục Thuế.

Lớp security tách Accountant — được draft + compute nhưng không unlink — với Accounting Manager — full control và duy nhất thấy button XML export. Một per-company rule chặn multi-company leak và một lock-after-export rule freeze draft sau khi XML đã generate. Demo data ship hai partner Việt Nam cùng một draft Quý 1/2024 cho reviewer cài module là thấy ngay realistic workflow.

Tính đến step này, module phủ kín toàn bộ chu trình GTGT của doanh nghiệp Việt Nam: kế toán (Thông tư 200 chart of accounts, step 2) → thuế (VAT 0/5/8/10% + fiscal position, step 3) → hóa đơn (mẫu số + ký hiệu + số hóa đơn ir.sequence, step 4) → đối tác (MST validation + địa chỉ, step 5) → tờ khai (Mẫu 01/GTGT compute + QWeb PDF, step 6) → nộp thuế (HTKK XML export + security + demo, step 7).

312 test xanh dưới nửa giây không cần Odoo runtime cho phép refactor công thức và format XML khi Thông tư sửa đổi với độ tin cậy cao. Vì vat_declaration_xml.py là pure-Python không phụ thuộc Odoo registry, toàn bộ XML structure và policy ranh giới đều được validate qua stdlib xml.etree.ElementTree + csv.DictReader — không cần spin up container để chạy CI.

Repository

The companion code for this article: https://github.com/vytharion/odoo-ke-toan-vat-vietnam

Key commits to step through:

  • 196cbd9 — step 1: Scaffold the Odoo addon module skeleton with __manifest__.py and account dependency
  • 873369e — step 2: Define the Vietnamese chart of accounts model and seed Thông tư 200 account codes
  • 24b74f8 — step 3: Configure VAT tax records (0%, 5%, 8%, 10%), tax groups, and fiscal position mapping
  • 012d2be — step 4: Build the hóa đơn GTGT model with mẫu số / ký hiệu fields and invoice number sequence
  • 6ff226a — step 5: Extend res.partner with MST (tax code) validation and Vietnamese address fields
  • 018c57a — step 6: Implement the Tờ khai thuế GTGT (Mẫu 01/GTGT) report view with QWeb and aggregation logic
  • 059ee5c — step 7: Add XML export for tax declaration submission, security rules, and demo data



📋 Đây là phiên bản demo mã nguồn mở. Để có bản đầy đủ với những tính năng vượt trội — hỗ trợ đa công ty, tích hợp iHTKK/eTax trực tiếp, báo cáo thuế TNDN, và triển khai production-ready — vui lòng liên hệ MercTechs.