Files
Odoo-Modules/nexa_coa_setup/hooks.py
gsinghpal 113427f7e2 feat(nexa_coa_setup): 8 fiscal positions + tax substitution maps
XML defines 8 positions with auto-detection by country/state:
- CA Ontario (default), CA Atlantic, CA Quebec, CA BC, CA Prairies/Territories
- Export US, Export International, Tax Exempt

post_init hook _configure_fiscal_position_tax_maps sets up bidirectional
tax routing (sale + purchase) from the default '5% GST' to the appropriate
provincial tax via Odoo 19's account.fiscal.position.tax_ids /
account.tax.original_tax_ids relation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:12:19 -04:00

324 lines
12 KiB
Python

# -*- coding: utf-8 -*-
import logging
_logger = logging.getLogger(__name__)
# l10n_ca account codes that collide with the Nexa CoA design and that
# l10n_ca pre-loads with 'income_other'/'expense'/etc. types we don't want.
# Each of these is checked at pre_init: if it has zero postings we suffix
# its code with '.OLD' and archive it so our XML can claim the code.
# Codes with postings are LEFT ALONE — we renumbered the Nexa code instead
# (115100 stays as l10n_ca 'Customers Account' AR; Nexa shareholder receivable
# moved to 119100. 511100 stays as l10n_ca 'Inside Purchases'; Nexa Cloud
# Infrastructure moved to 511105).
_L10N_CA_COLLISION_CODES = [
"118100", "118200", "118300",
"213100", "214100",
"221200",
"311100", "311200", "311300",
"411100", "411200", "411300",
"413100", "413200", "413300",
"511110", "511120", "511130", "511140", "511200", "511210",
"512100", "512110", "512200",
"611100", "611200", "611300",
"612100", "612200",
]
def pre_init_hook(env):
"""Run BEFORE XML data is loaded. Clear l10n_ca account codes that would
collide with Nexa's chart of accounts."""
_logger.info("nexa_coa_setup: pre_init_hook starting")
_clear_l10n_ca_collisions(env)
_logger.info("nexa_coa_setup: pre_init_hook complete")
def _clear_l10n_ca_collisions(env):
"""For each colliding code: if it has zero postings, rename to NNNNNN.OLD
and set inactive. If it has postings, leave alone (Nexa code was renumbered
in the XML to avoid the conflict)."""
cleared = 0
kept_with_postings = 0
not_found = 0
for code in _L10N_CA_COLLISION_CODES:
acc = env["account.account"].search([("code", "=", code)], limit=1)
if not acc:
not_found += 1
continue
usage = env["account.move.line"].search_count([("account_id", "=", acc.id)])
if usage > 0:
_logger.info(
"nexa_coa_setup: keeping l10n_ca account %s (%s) — %d postings exist",
code, acc.name, usage,
)
kept_with_postings += 1
continue
new_code = f"{code}.OLD"
# Skip if already suffixed (idempotency)
if acc.code.endswith(".OLD"):
continue
acc.write({
"code": new_code,
"name": f"(l10n_ca LEGACY) {acc.name or acc.display_name}",
"active": False,
})
cleared += 1
_logger.info(
"nexa_coa_setup: collision sweep — cleared %d, kept-with-postings %d, not-found %d",
cleared, kept_with_postings, not_found,
)
def post_init_hook(env):
"""Imperative one-shot operations after module data is loaded.
Each helper is idempotent — safe to re-run on -u.
"""
_logger.info("nexa_coa_setup: post_init_hook starting")
_normalize_company_hst_number(env)
_archive_unused_l10n_ca_accounts(env)
_rename_legacy_accounts(env)
_archive_unused_taxes(env)
_configure_fiscal_position_tax_maps(env)
_lock_fiscal_year_2025(env)
_logger.info("nexa_coa_setup: post_init_hook complete")
def _configure_fiscal_position_tax_maps(env):
"""For each Nexa fiscal position, set up tax substitution from default
'5% GST' to the appropriate provincial tax.
Odoo 19 fiscal position model:
- account.fiscal.position has tax_ids (M2M) — destination taxes
- account.tax has original_tax_ids (M2M) — source taxes it replaces
- Effective map is computed on the fly from both sides.
Idempotent: writes are 'replace' style (6, 0, [ids]) so re-running cleans
up prior runs.
"""
def find_tax(name, use):
return env["account.tax"].search(
[("name", "=", name), ("type_tax_use", "=", use), ("active", "=", True)],
limit=1,
)
GST_5_sale = find_tax("5% GST", "sale")
GST_5_purchase = find_tax("5% GST", "purchase")
HST_13_sale = find_tax("13% HST", "sale")
HST_13_purchase = find_tax("13% HST", "purchase")
HST_15_sale = find_tax("15% HST", "sale")
HST_15_purchase = find_tax("15% HST", "purchase")
QST_sale = find_tax("14.975% GST+QST", "sale")
QST_purchase = find_tax("14.975% GST+QST", "purchase")
ZERO_sale = find_tax("0% GST", "sale")
ZERO_purchase = find_tax("0% GST", "purchase")
# (fp_xmlid, [(source_tax, destination_tax), ...])
fp_map = [
("nexa_coa_setup.fp_ca_ontario", [
(GST_5_sale, HST_13_sale),
(GST_5_purchase, HST_13_purchase),
]),
("nexa_coa_setup.fp_ca_atlantic", [
(GST_5_sale, HST_15_sale),
(GST_5_purchase, HST_15_purchase),
]),
("nexa_coa_setup.fp_ca_quebec", [
(GST_5_sale, QST_sale),
(GST_5_purchase, QST_purchase),
]),
# CA-BC and CA-Prairies/Territories: default is already 5% GST, no substitution
("nexa_coa_setup.fp_ca_bc", []),
("nexa_coa_setup.fp_ca_prairies_territories", []),
("nexa_coa_setup.fp_export_us", [
(GST_5_sale, ZERO_sale),
]),
("nexa_coa_setup.fp_export_intl", [
(GST_5_sale, ZERO_sale),
]),
("nexa_coa_setup.fp_tax_exempt", [
(GST_5_sale, ZERO_sale),
]),
]
configured = 0
for fp_xmlid, pairs in fp_map:
fp = env.ref(fp_xmlid, raise_if_not_found=False)
if not fp:
_logger.warning("nexa_coa_setup: fiscal position not found: %s", fp_xmlid)
continue
# Clear existing destination taxes on the FP
fp.tax_ids = [(5, 0, 0)]
# For each (src, dst) pair, add dst to FP's tax_ids and src to dst's
# original_tax_ids
for src, dst in pairs:
if not src or not dst or src == dst:
continue
fp.tax_ids = [(4, dst.id, 0)]
existing_sources = dst.original_tax_ids.ids
if src.id not in existing_sources:
dst.original_tax_ids = [(4, src.id, 0)]
configured += 1
_logger.info("nexa_coa_setup: configured tax maps on %d fiscal positions", configured)
# Tax names to keep ACTIVE (covers GST/HST/QST/PST across provinces + zero-rated
# export + exempt). Everything else gets archived if it has zero usage on
# existing journal entries.
_KEEP_TAX_NAMES = {
"5% GST", "13% HST", "14% HST", "15% HST",
"11% GST+PST SK", "12% GST+PST BC", "12% GST+PST MB", "14.975% GST+QST",
"9.975% QST", "7% PST BC", "8% PST MB", "6% PST SK", "5% PST SK",
"0% GST", "0% Exempt", "0% Int",
}
def _archive_unused_taxes(env):
"""Archive active taxes whose name is NOT in _KEEP_TAX_NAMES AND that
have no usage on existing move lines. Preserves audit trail for historical
moves; just hides duplicates and unused defaults from the active set.
"""
keep_names = list(_KEEP_TAX_NAMES)
env.cr.execute(
"""
SELECT t.id
FROM account_tax t
WHERE t.active = true
AND COALESCE(t.name->>'en_US', '') != ALL(%s)
AND NOT EXISTS (
SELECT 1 FROM account_move_line_account_tax_rel r
WHERE r.account_tax_id = t.id
)
""",
(keep_names,),
)
ids = [r[0] for r in env.cr.fetchall()]
if not ids:
_logger.info("nexa_coa_setup: no unused taxes to archive")
return
env["account.tax"].browse(ids).write({"active": False})
_logger.info(
"nexa_coa_setup: archived %d unused taxes (kept set: %d names)",
len(ids), len(keep_names),
)
def _normalize_company_hst_number(env):
"""Convert '741224877' to '741224877 RT0001' if not already in full form."""
company = env.ref("base.main_company", raise_if_not_found=False)
if not company:
return
vat = (company.partner_id.vat or "").strip()
if vat == "741224877":
company.partner_id.vat = "741224877 RT0001"
_logger.info("nexa_coa_setup: normalized HST# to '741224877 RT0001'")
def _archive_unused_l10n_ca_accounts(env):
"""Archive l10n_ca accounts that have zero postings and don't belong to
nexa_coa_setup. Preserves history (active=False, never delete).
Idempotent: re-running has no effect on already-archived accounts.
"""
env.cr.execute(
"""
SELECT a.id
FROM account_account a
WHERE a.active = true
AND NOT EXISTS (
SELECT 1 FROM account_move_line aml WHERE aml.account_id = a.id
)
AND NOT EXISTS (
SELECT 1 FROM ir_model_data d
WHERE d.model = 'account.account'
AND d.res_id = a.id
AND d.module = 'nexa_coa_setup'
)
"""
)
ids = [r[0] for r in env.cr.fetchall()]
if not ids:
_logger.info("nexa_coa_setup: no unused accounts to archive")
return
env["account.account"].browse(ids).write({"active": False})
_logger.info("nexa_coa_setup: archived %d unused accounts", len(ids))
# Legacy accounts (from Gurpreet's prior bookkeeping) to rename + archive.
# These all have postings, so we mark them "(LEGACY)" so they stop appearing
# in regular dropdowns but their history is preserved for future
# accountant-driven reconciliation.
_LEGACY_RENAMES = [
# (code, new_name, archive_after)
("1400", "(LEGACY) Transferred to Gurpreet — re-class to 221100", True),
("1505", "(LEGACY) Sent to India — re-class to 612200", True),
("1580", "(LEGACY) Transferred to Westin — Westin is now a partner", True),
("1590", "(LEGACY) Transferred to Divine — Divine is now a partner", True),
("1600", "(LEGACY) Transferred to Manpreet — non-related; archive", True),
("1500", "(LEGACY) Food & Entertainment — re-class to 671200", True),
("1501", "(LEGACY) Office Expenses — re-class to 621500", True),
("411000", "(LEGACY) Inside Sales — re-class to 412xxx specific lines", True),
("412000", "(LEGACY) Harmonized Provinces Sales — handled by tax codes", True),
("413000", "(LEGACY) Non-Harmonized Provinces Sales — handled by tax", True),
("414000", "(LEGACY) International Sales — handled by Zero-rated Export", True),
("12000", "(LEGACY) Abdul & Future Mobility — use partner subledger", True),
("12001", "(LEGACY) MSI Account — use partner subledger", True),
("110010", "(LEGACY) Bank Fee — re-class to 691100", True),
]
def _rename_legacy_accounts(env):
"""Rename + archive the legacy accounts from prior bookkeeping.
Idempotent: accounts already prefixed with '(LEGACY)' are skipped.
"""
renamed = 0
archived = 0
for code, new_name, archive in _LEGACY_RENAMES:
# active_test=False so we also rename accounts that were already
# archived by _archive_unused_l10n_ca_accounts (e.g., 413000 sales bucket).
accs = env["account.account"].with_context(active_test=False).search([("code", "=", code)])
for acc in accs:
if (acc.name or "").startswith("(LEGACY)"):
continue
acc.name = new_name
renamed += 1
if archive and acc.active:
acc.active = False
archived += 1
_logger.info(
"nexa_coa_setup: renamed %d legacy accounts, archived %d",
renamed, archived,
)
def _lock_fiscal_year_2025(env):
"""Try to set fiscalyear_lock_date = 2025-12-31 on main company.
If Odoo blocks the lock because unreconciled bank statement lines or other
open items exist in the period, log a clear warning and continue. The user
can set the lock manually via Accounting > Configuration > Settings > Lock
Dates once those items are cleaned up.
"""
from datetime import date
from odoo.exceptions import RedirectWarning, UserError, ValidationError
company = env.ref("base.main_company", raise_if_not_found=False)
if not company:
return
target = date(2025, 12, 31)
if company.fiscalyear_lock_date and company.fiscalyear_lock_date >= target:
_logger.info("nexa_coa_setup: fiscalyear_lock_date already at or after 2025-12-31")
return
try:
company.fiscalyear_lock_date = target
_logger.info("nexa_coa_setup: fiscalyear_lock_date set to 2025-12-31")
except (RedirectWarning, UserError, ValidationError) as exc:
_logger.warning(
"nexa_coa_setup: could not auto-lock fiscal year 2025-12-31. "
"Reason: %s. Set the lock manually via Accounting > Configuration > "
"Settings > Lock Dates after the unreconciled items in the period "
"are cleaned up.",
exc,
)