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>
This commit is contained in:
gsinghpal
2026-05-12 19:19:49 -04:00
parent d2f8934a53
commit 9c52fac9ba
2 changed files with 177 additions and 26 deletions

View File

@@ -80,22 +80,89 @@ def post_init_hook(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):
"""For each Nexa fiscal position, set up tax substitution from default
'5% GST' to the appropriate provincial tax.
"""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
- 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.
Idempotent: writes are 'replace' style so re-running cleans up prior runs.
"""
def find_tax(name, use):
return env["account.tax"].search(
@@ -103,42 +170,67 @@ def _configure_fiscal_position_tax_maps(env):
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")
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")
# (fp_xmlid, [(source_tax, destination_tax), ...])
# 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", [
(GST_5_sale, HST_13_sale),
(GST_5_purchase, HST_13_purchase),
(HST_13_sale, HST_13_sale),
(HST_13_purchase, HST_13_purchase),
]),
("nexa_coa_setup.fp_ca_atlantic", [
(GST_5_sale, HST_15_sale),
(GST_5_purchase, HST_15_purchase),
(HST_13_sale, HST_15_sale),
(HST_13_purchase, HST_15_purchase),
]),
("nexa_coa_setup.fp_ca_quebec", [
(GST_5_sale, QST_sale),
(GST_5_purchase, QST_purchase),
(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),
]),
# 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),
(HST_13_sale, ZERO_sale),
]),
("nexa_coa_setup.fp_export_intl", [
(GST_5_sale, ZERO_sale),
(HST_13_sale, ZERO_sale),
]),
("nexa_coa_setup.fp_tax_exempt", [
(GST_5_sale, ZERO_sale),
(HST_13_sale, ZERO_sale),
]),
]
@@ -148,16 +240,16 @@ def _configure_fiscal_position_tax_maps(env):
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:
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)]
existing_sources = dst.original_tax_ids.ids
if src.id not in existing_sources:
# 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)

View File

@@ -0,0 +1,59 @@
"""Test invoices using Form to emulate UI onchange behavior."""
from odoo.tests.common import Form
import json
results = []
def test_invoice(label, partner_vals, product_name, product_cat_xmlid, price=100.0):
env.cr.execute("SAVEPOINT test")
try:
partner = env['res.partner'].search([('name', '=', partner_vals['name'])], limit=1)
if not partner:
partner = env['res.partner'].create(partner_vals)
cat = env.ref(product_cat_xmlid)
product = env['product.product'].search([('name', '=', product_name)], limit=1)
if not product:
product = env['product.product'].create({
'name': product_name, 'type': 'service',
'list_price': price, 'categ_id': cat.id,
})
with Form(env['account.move'].with_context(default_move_type='out_invoice')) as inv_form:
inv_form.partner_id = partner
with inv_form.invoice_line_ids.new() as line:
line.product_id = product
line.quantity = 1
line.price_unit = price
inv = inv_form.save()
line = inv.invoice_line_ids[0]
results.append({
'label': label,
'fiscal_position': inv.fiscal_position_id.name or '(none)',
'taxes': line.tax_ids.mapped('name'),
'income_account': f"{line.account_id.code} {line.account_id.name}",
'subtotal': inv.amount_untaxed,
'tax_total': inv.amount_tax,
'total': inv.amount_total,
'partner_tags': [c.name for c in partner.category_id],
})
finally:
env.cr.execute("ROLLBACK TO SAVEPOINT test")
test_invoice("Ontario (HST 13%)",
{'name': 'TEST CUST ON', 'country_id': env.ref('base.ca').id, 'state_id': env.ref('base.state_ca_on').id, 'customer_rank': 1},
'TEST SaaS', 'nexa_coa_setup.pc_saas', 100.0)
test_invoice("US (Zero-rated)",
{'name': 'TEST CUST US', 'country_id': env.ref('base.us').id, 'customer_rank': 1},
'TEST SaaS', 'nexa_coa_setup.pc_saas', 100.0)
test_invoice("Quebec (GST+QST)",
{'name': 'TEST CUST QC', 'country_id': env.ref('base.ca').id, 'state_id': env.ref('base.state_ca_qc').id, 'customer_rank': 1},
'TEST SaaS', 'nexa_coa_setup.pc_saas', 100.0)
test_invoice("Intercompany -> Westin",
{'name': 'Westin Healthcare Inc'},
'TEST Consulting', 'nexa_coa_setup.pc_consulting', 150.0)
for r in results:
print("---")
print(json.dumps(r, indent=2, default=str))