Files
Odoo-Modules/nexa_coa_setup/hooks.py
gsinghpal 9c52fac9ba fix(nexa_coa_setup): default tax = 13% HST, tax repartition migration, FP pass-through
Three fixes that unblock end-to-end invoice tests on staging:

1. Switched company default sale/purchase tax from '5% GST' to '13% HST'
   (Ontario is the home province). New products auto-get 13% HST; fiscal
   positions substitute OUT to other rates per customer location.

2. Added _migrate_tax_repartition_accounts hook. The post_init archive sweep
   correctly archived legacy l10n_ca tax-tracking accounts (118100.OLD,
   231000, 232000, 233000, 118400, 118500, etc.) but active taxes still
   referenced them via repartition lines, causing invoice posting to fail
   with 'account is archived'. Hook repoints repartition to Nexa's
   consolidated 118100 (ITC) / 213100 (HST collected) / 213500 (QST
   collected) accounts.

3. Odoo 19 fiscal position behavior change: empty tax_ids now means
   'remove all taxes' (was 'pass-through' in v17/18). For ON home position
   we now add a self-mapping placeholder (13% HST -> 13% HST) so the FP
   has a non-empty tax_ids and map_tax falls through to pass-through
   semantics on the 13% HST source.

Verified with 4 invoice tests on staging:
  ON     -> 13% HST   total 113.00
  US     -> 0% GST    total 100.00 (zero-rated export)
  QC     -> 14.975%   total 114.98
  Westin -> 13% HST   total 169.50 (intercompany, RP-Associated tag)

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

416 lines
17 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)
_migrate_tax_repartition_accounts(env)
_configure_fiscal_position_tax_maps(env)
_lock_fiscal_year_2025(env)
_logger.info("nexa_coa_setup: post_init_hook complete")
# Map of legacy l10n_ca account codes (some now suffixed '.OLD', some still
# raw codes that were archived) → Nexa consolidated tax accounts.
# Used by _migrate_tax_repartition_accounts to repoint tax repartition lines
# at the new accounts, so when an invoice creates tax journal items they hit
# 118100 (ITC) and 213100 (HST collected) instead of the archived legacy ones.
_TAX_REPARTITION_REMAP = {
# ITC / receivable side
"118100.OLD": "118100",
"118200.OLD": "118300", # PST/QST receivable → QST refund receivable
"118300.OLD": "118100",
"118400": "118100", # 14% HST receivable
"118500": "118100", # 15% HST receivable
# Payable / collected side
"231000": "213100", # GST to pay
"232000": "213500", # PST/QST to pay
"233000": "213100", # HST 13% to pay
"234000": "213100", # HST 14% to pay
"235000": "213100", # HST 15% to pay
}
def _migrate_tax_repartition_accounts(env):
"""Repoint account_tax_repartition_line.account_id from legacy l10n_ca
accounts to Nexa's consolidated tax accounts (118100 ITC, 213100 HST
collected, 213500 QST collected). Without this, posting an invoice that
uses an active tax (e.g., 13% HST) fails because the repartition still
points at archived accounts.
Idempotent: only touches repartition lines whose current account is in
the legacy set.
"""
legacy_codes = list(_TAX_REPARTITION_REMAP.keys())
legacy_accounts = env["account.account"].with_context(active_test=False).search([
("code", "in", legacy_codes),
])
if not legacy_accounts:
_logger.info("nexa_coa_setup: no legacy tax accounts found; repartition migration skipped")
return
# Build target ID lookup (target accounts must exist and be active)
target_ids = {}
for legacy_code, target_code in _TAX_REPARTITION_REMAP.items():
target = env["account.account"].search([("code", "=", target_code), ("active", "=", True)], limit=1)
if target:
target_ids[legacy_code] = target.id
migrated = 0
for legacy in legacy_accounts:
target_id = target_ids.get(legacy.code)
if not target_id:
continue
rep_lines = env["account.tax.repartition.line"].search([("account_id", "=", legacy.id)])
if rep_lines:
rep_lines.write({"account_id": target_id})
migrated += len(rep_lines)
_logger.info("nexa_coa_setup: migrated %d tax repartition lines to Nexa accounts", migrated)
def _configure_fiscal_position_tax_maps(env):
"""Configure default Ontario taxes on the company + tax substitution maps
on each Nexa fiscal position.
Design: Nexa is Ontario-based, so:
- Default sale tax = '13% HST' (sale)
- Default purchase tax = '13% HST' (purchase)
- Fiscal positions substitute OUT to other rates per customer location:
ON → no substitution (already 13%)
Atlantic → 15% HST
Quebec → 14.975% GST+QST
BC/Prairies/Territories → 5% GST only
US/Export/Exempt → 0% GST
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
Idempotent: writes are 'replace' style 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,
)
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")
GST_5_sale = find_tax("5% GST", "sale")
GST_5_purchase = find_tax("5% GST", "purchase")
ZERO_sale = find_tax("0% GST", "sale")
ZERO_purchase = find_tax("0% GST", "purchase")
# Set company-default sale/purchase taxes to Ontario HST 13% (the clean
# named ones, not the l10n_ca 'HST for sales - 13%' legacy).
company = env.ref("base.main_company", raise_if_not_found=False)
if company:
if HST_13_sale:
company.account_sale_tax_id = HST_13_sale.id
if HST_13_purchase:
company.account_purchase_tax_id = HST_13_purchase.id
_logger.info(
"nexa_coa_setup: set company default sale tax = %s, purchase tax = %s",
HST_13_sale.name if HST_13_sale else "(none)",
HST_13_purchase.name if HST_13_purchase else "(none)",
)
# Each entry: (fp_xmlid, [(source, destination), ...])
# Note: Odoo 19's account.fiscal.position.map_tax treats EMPTY tax_ids as
# "remove ALL taxes" (tax-unit semantics). To get the legacy "pass-through"
# behavior we have to put at least one destination tax in tax_ids — even
# if no substitution is needed. We use a self-mapping (13% HST → 13% HST)
# which is filtered out below as no-op but still populates tax_ids.
fp_map = [
# Ontario is the home: pass-through via self-mapping placeholder
("nexa_coa_setup.fp_ca_ontario", [
(HST_13_sale, HST_13_sale),
(HST_13_purchase, HST_13_purchase),
]),
("nexa_coa_setup.fp_ca_atlantic", [
(HST_13_sale, HST_15_sale),
(HST_13_purchase, HST_15_purchase),
]),
("nexa_coa_setup.fp_ca_quebec", [
(HST_13_sale, QST_sale),
(HST_13_purchase, QST_purchase),
]),
("nexa_coa_setup.fp_ca_bc", [
(HST_13_sale, GST_5_sale),
(HST_13_purchase, GST_5_purchase),
]),
("nexa_coa_setup.fp_ca_prairies_territories", [
(HST_13_sale, GST_5_sale),
(HST_13_purchase, GST_5_purchase),
]),
("nexa_coa_setup.fp_export_us", [
(HST_13_sale, ZERO_sale),
]),
("nexa_coa_setup.fp_export_intl", [
(HST_13_sale, ZERO_sale),
]),
("nexa_coa_setup.fp_tax_exempt", [
(HST_13_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
fp.tax_ids = [(5, 0, 0)]
for src, dst in pairs:
if not src or not dst:
continue
# Always add dst to fp.tax_ids so the FP isn't "empty"
# (Odoo 19 treats empty tax_ids as remove-all-taxes).
fp.tax_ids = [(4, dst.id, 0)]
# If src == dst, no original_tax_ids link needed — the map_tax
# fallback returns the source unchanged when no mapping is found.
if src.id != dst.id and src.id not in dst.original_tax_ids.ids:
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,
)