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:
@@ -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)
|
||||
|
||||
59
nexa_coa_setup/scripts/test_invoices.py
Normal file
59
nexa_coa_setup/scripts/test_invoices.py
Normal 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))
|
||||
Reference in New Issue
Block a user