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>
416 lines
17 KiB
Python
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,
|
|
)
|